From fbf83f11ef96f5a3ce772467c377eab3d159ad70 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 17:27:17 +0000 Subject: [PATCH 01/14] fix(stamp): name branch lines by position; carry and finish accept a kept VERSION claim (GST-979, GST-980) BranchSide carries the positions of the branch-added lines and stampOnlyLine takes the removed line position, so a base line whose text duplicates a branch line keeps the old-version floor. At equal versions only the branch own lines may move. stampOnlyText and stampedFiles accept a kept claim for those lines, and the carry leaves a stamp file out of the product comparison when its only change since the reviewed head is a stamp on the branch own rows and the base did not touch it. Failing-first: all five new tests fail on main fc292e22f. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-receipt | 18 +++++--- changelog.d/gst-979-980-stamp-positions.md | 14 ++++++ docs/ship-policy.md | 2 +- lib/restamp.ts | 15 ++++-- lib/ship-bin-common.ts | 9 ++-- lib/ship-receipt.ts | 19 +++++--- lib/stamp-only.ts | 27 +++++++---- lib/tree-receipt.ts | 2 +- test/gstack-ship-receipt-docs.test.ts | 19 +++++++- test/restamp-mentions.test.ts | 54 ++++++++++++++++++++++ test/ship-rereview-triggers.test.ts | 18 ++++++++ test/tree-receipt-stamp-sensitive.test.ts | 16 +++++++ 12 files changed, 178 insertions(+), 35 deletions(-) create mode 100644 changelog.d/gst-979-980-stamp-positions.md diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 773fb19c1..c8aaa08b5 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -496,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; sides: ReadonlyArray; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { +function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet; sides: ReadonlyArray; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[]; demoted: 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; }; @@ -514,7 +514,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path) : [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product'))); const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p))); - return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] }; + return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted }; } /** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */ @@ -645,6 +645,7 @@ function cmdCarry(rest: string[], json: boolean): number { const current = retarget ? headPatch : productHead; const changedProduct: string[] = []; const contextPaths: string[] = []; + const stampOnly: string[] = []; if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`); else if (productFrom !== productHead && !retarget) { // planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the @@ -656,22 +657,27 @@ function cmdCarry(rest: string[], json: boolean): number { const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)]; if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`); const [movedHead, movedBase] = moved as Set[]; + // A stamp file the branch changed as product (its own row) whose reviewed-to-current change is a stamp on its own lines + // and that the base did not touch holds no unreviewed change, so the comparison leaves its bytes out (GST-980). for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); + if (cls === 'metadata' && spec.demoted.includes(p) && !movedBase.has(p)) stampOnly.push(p); if (cls !== 'product') continue; const verdict = comparePath(repoRoot, sides, p); if (verdict === 'changed') changedProduct.push(p); else if (verdict === 'context') contextPaths.push(p); } - if (!changedProduct.length && !contextPaths.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); + const left = [...contextPaths, ...stampOnly]; + if (!changedProduct.length && !left.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); else if (!changedProduct.length) { - const rest = [...spec.product, ...contextPaths.map(p => `:(exclude,literal)${p}`)]; + const rest = [...spec.product, ...left.map(p => `:(exclude,literal)${p}`)]; const restFrom = patchId(repoRoot, fromBase, from, rest); const restHead = patchId(repoRoot, headBase, head, rest); - if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${contextPaths.join(', ')}; compare the two base diffs without them)`); + if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${left.join(', ')}; compare the two base diffs without them)`); } } + const stampedRows = changedProduct.length ? [] : stampOnly; let scopedReview: string | undefined; const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null; if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`)); @@ -701,7 +707,7 @@ function cmdCarry(rest: string[], json: boolean): number { // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; - plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}) }); + plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}), ...(stampedRows.length ? { stampedRows } : {}) }); } if (!plan.ok) return refused(plan.code, plan.message); // Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD, diff --git a/changelog.d/gst-979-980-stamp-positions.md b/changelog.d/gst-979-980-stamp-positions.md new file mode 100644 index 000000000..2f941aa3e --- /dev/null +++ b/changelog.d/gst-979-980-stamp-positions.md @@ -0,0 +1,14 @@ +**The stamp checks name the branch's lines by position, and the carry and finish accept a kept VERSION claim (GST-979, GST-980).** After GST-963, three gaps were left in the checks that accept a stamp commit, all found by the #3862 reviewers. A base line whose text duplicated a line the branch added got the branch's fork-version bound, so a hand edit of that base copy passed the tree receipt. When a train slot kept its claimed VERSION, a base line moved down to that version also read as a stamp. And the /ship receipt carry and finish refused the fork-version row move the restamp now makes: the carry kept the branch-added row in both product patch-ids (`RECEIPT_CARRY_PRODUCT_CHANGED`), and with a kept VERSION claim the carry and finish read every change as product or behavior. + +### Fixed +- **`lib/stamp-only.ts`:** `BranchSide` carries the positions of the branch's lines, and `stampOnlyLine` takes the removed line's position. Only a line at a branch-added position gets the fork-version bound. When the version does not move, only such a line may change. +- **`lib/restamp.ts`:** `parseUnifiedDiff` records each removed line's position on the old side, which the tree receipt, the train checks and finish's `stampedFiles` pass along. +- **`lib/ship-receipt.ts`, `lib/ship-bin-common.ts`:** `stampOnlyText` and `stampedFiles` accept a kept VERSION claim for the branch's own rows and nothing else. +- **`bin/gstack-ship-receipt` carry:** a stamp file the branch changed as product, whose only change since the reviewed head is a stamp on its own rows and which the base did not touch, is left out of the product comparison. The carry records `a stamp on the branch's own rows ()` as the reason. A product edit anywhere else still refuses. + +### Tests +- `test/tree-receipt-stamp-sensitive.test.ts`: the bound holds only at the branch line's position, and a kept VERSION refuses a base line moved down. +- `test/restamp-mentions.test.ts`: a hand move of a base row whose text the branch duplicates reads `TREE: changed`, and the carry follows the fork-version row stamp but still refuses a product edit beside it. +- `test/gstack-ship-receipt-docs.test.ts`, `test/ship-rereview-triggers.test.ts`: the carry classifier and finish accept the kept-claim row move and refuse a base row moved the same way. + +On main, all five new tests fail. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 4adac84b1..d9fcabec6 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1529,7 +1529,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 merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), 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. A base line whose text duplicates a line the branch added gets the branch's bound too, because the checks match branch lines by text, not position (GST-979). The /ship receipt carry can still refuse the move (`RECEIPT_CARRY_PRODUCT_CHANGED`), and when the branch keeps its VERSION claim, carry and finish still route the move as behavior (GST-980). +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. A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when the base did not touch that file and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980). 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/restamp.ts b/lib/restamp.ts index 45dd82ff7..be2c6fc23 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -244,7 +244,7 @@ function allowedOutput(plan: Plan, root: string, rel: string): boolean { return matchesAny(p.stamp_paths, rel) || matchesAny(p.stamp_files, rel) || matchesAny(p.release_outputs, rel); } -interface Hunk { removed: string[]; added: string[]; at: number; addedAt: number[] } +interface Hunk { removed: string[]; added: string[]; at: number; addedAt: number[]; removedAt: number[] } export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status: 'M' | 'A' | 'D' | 'R'; modeChanged?: boolean } /** @@ -252,13 +252,15 @@ export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status * File headers come only before a file's first hunk: inside a hunk every `-`/`+` line is content, so a * body line that begins `--` or `++` (`---`/`+++` in the diff) is never dropped as a header. * `addedAt` holds each added line's 1-based position on the new side, counted through any context - * line the hunk carries (`diff.interHunkContext` can join hunks under `--unified=0`). + * line the hunk carries (`diff.interHunkContext` can join hunks under `--unified=0`), and `removedAt` each removed + * line's position on the old side, counted the same way. */ export function parseUnifiedDiff(text: string): FileDiff[] { const files: FileDiff[] = []; let cur: FileDiff | null = null; let hunk: Hunk | null = null; let cursor = 0; + let oldCursor = 0; for (const line of text.split('\n')) { if (line.startsWith('diff --git ')) { const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line); @@ -271,10 +273,13 @@ export function parseUnifiedDiff(text: string): FileDiff[] { else if (!hunk && (line.startsWith('old mode ') || line.startsWith('new mode '))) cur.modeChanged = true; else if (line.startsWith('rename to ')) cur.status = 'R'; else if (line.startsWith('Binary files')) cur.binary = true; - else if (line.startsWith('@@')) { hunk = { removed: [], added: [], at: Number(/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line)?.[1] ?? 0), addedAt: [] }; cursor = hunk.at; cur.hunks.push(hunk); } - else if (hunk && line.startsWith('-')) hunk.removed.push(line.slice(1)); + else if (line.startsWith('@@')) { + const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)/.exec(line); + hunk = { removed: [], added: [], at: Number(m?.[2] ?? 0), addedAt: [], removedAt: [] }; cursor = hunk.at; oldCursor = Number(m?.[1] ?? 0); cur.hunks.push(hunk); + } + else if (hunk && line.startsWith('-')) { hunk.removed.push(line.slice(1)); hunk.removedAt.push(oldCursor); oldCursor += 1; } else if (hunk && line.startsWith('+')) { hunk.added.push(line.slice(1)); hunk.addedAt.push(cursor); cursor += 1; } - else if (hunk && line.startsWith(' ')) cursor += 1; + else if (hunk && line.startsWith(' ')) { cursor += 1; oldCursor += 1; } } return files; } diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 6bdd582dc..d1b8115d3 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -111,7 +111,8 @@ export function releasePaths(policy: Record | null, cwd: string, re * A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule: * added since the merge base with origin/, and not added by the base since): after a lost slot and a merge of the * base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead, - * the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). + * the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). A branch line is + * named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980). */ export function stampedFiles(policy: Record | null, cwd: string, ref: string, base: string): string[] { const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : []; @@ -120,14 +121,14 @@ export function stampedFiles(policy: Record | null, cwd: string, re if (!('version' in before)) return []; let after = ''; try { after = extractVersion(fs.readFileSync(path.join(cwd, before.rel), 'utf8'), before.rel); } catch { return []; } - if (!after || after === before.version) return []; + if (!after) return []; 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 []; 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)); + const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at); 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); + f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!, h.removedAt[i]!)))).map(f => f.path); } /** Under `changelog_fragments`, the branch's own Step 13 entry `/.md` (docs/ship-policy.md#changelog-fragments); null without the key. */ diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 1b1c5ed83..3866df4de 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -178,6 +178,8 @@ export interface CarryPlanInput { retargetedFrom?: string; /** A clean scoped review of product paths whose only difference is base context next to unchanged hunks (lib/ship-patch.ts, GST-384): the carry reason it adds. */ scopedReview?: string; + /** Stamp files the branch changed as product whose only change since the reviewed head is a stamp on the branch's own rows, the base untouched, and without which the two product patches match (GST-980). */ + stampedRows?: string[]; } export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' } export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string }; @@ -195,22 +197,23 @@ export interface CarryChange { before: string | null; after: string | null; oldV * 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, and - * on a line the branch added itself, `side`, the fork version or newer, GST-963): the rule + * on a line the branch added itself, `side`, the fork version or newer, GST-963; with a kept VERSION only such a line, + * GST-980): 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. + * change outside the branch's own lines, or a file missing on either side, is not a stamp. */ export function stampOnlyText(change: CarryChange): boolean { const { before, after, oldVersion, newVersion } = change; - if (before === null || after === null || !oldVersion || !newVersion || oldVersion === newVersion) return false; + if (before === null || after === null || !oldVersion || !newVersion || (oldVersion === newVersion && !change.side)) return false; // Digit-bounded: replacing 1.2.3 inside 11.2.3 or 1.2.34 would read a different version's stamp as this one's. const escaped = newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (after.replace(new RegExp(`(? i).filter(i => was[i] !== now[i]); - return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side)); + return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side, i + 1)); } /** @@ -241,16 +244,18 @@ export function classifyCarryPath(rel: string, extra: { metadata?: readonly stri * conflict-only base merge, a test-only fix or a release-metadata change * leaves behind. A product patch that differs is RECEIPT_CARRY_PRODUCT_CHANGED, * whatever else changed, unless every difference is base context next to unchanged hunks - * and a scoped review of those hunks came back clean (`scopedReview`). + * and a scoped review of those hunks came back clean (`scopedReview`), or a stamp on the + * branch's own rows in a stamp file the branch also changed as product (`stampedRows`, GST-980). */ export function planCarry(input: CarryPlanInput): CarryPlan { - if (input.productPatchId.from !== input.productPatchId.head && (input.changedProduct.length || !input.scopedReview)) { + if (input.productPatchId.from !== input.productPatchId.head && (input.changedProduct.length || (!input.scopedReview && !input.stampedRows?.length))) { const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS); return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` }; } const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : []; if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`); if (input.scopedReview && input.productPatchId.from !== input.productPatchId.head) reasons.push(input.scopedReview); + if (input.stampedRows?.length && input.productPatchId.from !== input.productPatchId.head) reasons.push(`a stamp on the branch's own rows (${input.stampedRows.join(', ')})`); if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change'); if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata'); if (input.changed.some(c => c.class === 'generated')) reasons.push('regenerated outputs'); diff --git a/lib/stamp-only.ts b/lib/stamp-only.ts index 3b0d2d1e7..bbb87dc66 100644 --- a/lib/stamp-only.ts +++ b/lib/stamp-only.ts @@ -20,18 +20,25 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source'; * 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. + * With `side`, a removed line the branch added itself, named by its 1-based + * position `at` in the side's file (GST-979: a base line with the same text + * is not the branch's), 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. When the + * version does not move (a slot that keeps its claimed VERSION), only such a + * branch line may change: the stage moves nothing else then. */ -export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide): boolean { +export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number): boolean { if (removed === added) return false; - return stampOnlyWithin(removed, added, oldV, newV, templates) || (!!side?.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates)); + if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true; + return !!side && at !== undefined && side.at.has(at) && 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 } +/** + * Lines the branch added to one file (since its merge base with the base, less the lines the base added since): their + * text and their 1-based positions in the side's file, and its fork point's version. + */ +export interface BranchSide { fork: string; added: ReadonlySet; at: ReadonlySet } /** * Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to` @@ -48,8 +55,8 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal 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 }; + const own = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel); + if (own?.text.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added: own.text, at: own.at }; } catch {} } sides.set(rel, side); diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index 7ba350205..f5d46b053 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -86,7 +86,7 @@ function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, te 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, side)) 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, h.removedAt[i])) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; } } return null; diff --git a/test/gstack-ship-receipt-docs.test.ts b/test/gstack-ship-receipt-docs.test.ts index 497d49c07..db89a68fb 100644 --- a/test/gstack-ship-receipt-docs.test.ts +++ b/test/gstack-ship-receipt-docs.test.ts @@ -225,12 +225,29 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit 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]) }; + const side = { fork: '1.2.3.0', added: new Set([mine]), at: new Set([1]) }; 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); }); + // Value: protects=the carry reads the branch's own line by position and, with a kept VERSION claim, accepts only the + // branch's own row moved from the fork version (GST-979, GST-980); fails_when=stampOnlyText refuses every change at + // equal versions or takes a duplicated base line as the branch's; seam=lib/ship-receipt.ts stampOnlyText + test('stampOnlyText reads the branch line by position, and with a kept VERSION moves only the branch\'s own row (GST-979, GST-980)', () => { + const templates = ["_KEY_VERSION = '{version}'"]; + const key = (v: string) => `export const MINE_KEY_VERSION = '${v}';`; + const before = `${key('1.2.3.0')}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n${key('1.2.3.0')}\n`; + const side = { fork: '1.2.3.0', added: new Set([key('1.2.3.0')]), at: new Set([3]) }; + const lines = (a: string, c: string) => `${a}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n${c}\n`; + const change = (after: string, oldVersion: string, newVersion: string) => stampOnlyText({ before, after, oldVersion, newVersion, templates, side }); + expect(change(lines(key('1.2.3.0'), key('1.2.5.0')), '1.2.4.0', '1.2.5.0')).toBe(true); + expect(change(lines(key('1.2.5.0'), key('1.2.3.0')), '1.2.4.0', '1.2.5.0')).toBe(false); + expect(change(lines(key('1.2.3.0'), key('1.2.4.0')), '1.2.4.0', '1.2.4.0')).toBe(true); + expect(change(lines(key('1.2.4.0'), key('1.2.3.0')), '1.2.4.0', '1.2.4.0')).toBe(false); + expect(stampOnlyText({ before, after: lines(key('1.2.3.0'), key('1.2.4.0')), oldVersion: '1.2.4.0', newVersion: '1.2.4.0', templates })).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: '' }); diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index 3913d7e92..4c882b89e 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -329,4 +329,58 @@ describe('a branch-added row left at the fork version after the base moved (GST- expect(receipt.out).toContain("id: 'base_row'"); expect(receipt.out).not.toContain("id: 'mine'"); }); + + // Value: protects=a base row whose text a branch row duplicates keeps the old-version floor in the tree receipt + // (GST-979, the #3862 outside voice); fails_when=the side matches by text, so a hand move of the base copy reads as + // same-modulo-stamps; seam=lib/stamp-only.ts branchSides positions + test('a base row whose text the branch duplicates still refuses a hand move; the branch copy\'s own move passes (GST-979)', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-dup-row-', 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-dup'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', '', row('1.2.3.0', 'base_row')])); + repo.git('commit', '-qam', 'dup: the same row text at the fork version'); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); + expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), '', '', row('1.2.5.0', 'base_row')])); + repo.git('commit', '-qam', 'stamp dup'); + const own = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(own.out, own.err).toContain('TREE: same-modulo-stamps'); + write(repo, NOTICE, notice([row('1.2.5.0', 'base_row'), '', '', row('1.2.5.0', 'base_row')])); + repo.git('commit', '-qam', 'move the base copy by hand'); + const hand = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(hand.out, hand.err).toContain('TREE: changed'); + }); + + // Value: protects=the receipt carry follows a stamp that moves the branch's own fork-version row, the move the writer + // makes since GST-963 (GST-980); fails_when=the carry keeps the branch-added row's bytes in both product patch-ids and + // refuses RECEIPT_CARRY_PRODUCT_CHANGED; seam=bin/gstack-ship-receipt carry walk + test('the carry follows a stamp that moves the branch\'s own fork-version row, and still refuses a product edit beside it (GST-980)', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-carry-row-', policy: policy(true) }); repos.push(repo); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')])); + fs.rmSync(path.join(repo.work, 'package-lock.json')); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-carry'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.3.0', 'mine')])); + repo.git('commit', '-qam', 'mine: claims 1.2.4.0 with a row at the fork version'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]); + expect(w.status, w.err).toBe(EXIT.ok); + expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); + expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')])); + repo.git('commit', '-qam', 'stamp 1.2.5.0'); + const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(carried.err).not.toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(carried.out, carried.err).toContain('CARRY: carried'); + write(repo, 'src.ts', 'export const x = 1;\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'a product change after the stamp'); + const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(changed.status).toBe(EXIT.refused); + expect(changed.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + }); }); diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 4e465c0a4..63bc87850 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -248,6 +248,24 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]); expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]); }); + + // Value: protects=with a kept VERSION claim, the restamp's move of the branch's own fork-version row routes as metadata, + // and a base row moved the same way still stops (GST-980); fails_when=stampedFiles returns nothing at equal versions + // and finish routes the row as behavior; seam=lib/ship-bin-common.ts stampedFiles + test('a kept VERSION claim: the moved fork-version row is metadata; a moved base row stops (GST-980)', () => { + const kept = (rows: [string, string][]) => { + const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.0.0', 'base']) }, + { VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.0.0', 'base'], ['1.0.0.0', 'mine']) }); + const reviewed = wtree(f); + const post = snapshot(f); + f.write({ 'src/notice.ts': notice(...rows) }); f.commit('stamp, VERSION kept'); + return finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]); + }; + const own = kept([['0.9.0.0', 'old'], ['1.0.0.0', 'base'], ['1.0.1.0', 'mine']]); + expect(lineOf(own.stdout, 'ROUTE')).toEqual(expect.stringMatching(/ behavior=0 docs=0 metadata=1 tests=0 /)); + const base = kept([['0.9.0.0', 'old'], ['1.0.1.0', 'base'], ['1.0.0.0', 'mine']]); + expect([base.status, lineOf(base.stdout, 'STOP')]).toEqual([3, behaviorStop]); + }); }); describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => { diff --git a/test/tree-receipt-stamp-sensitive.test.ts b/test/tree-receipt-stamp-sensitive.test.ts index 569cc83cd..e9c56e660 100644 --- a/test/tree-receipt-stamp-sensitive.test.ts +++ b/test/tree-receipt-stamp-sensitive.test.ts @@ -105,6 +105,22 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', () expect(width.out).toMatch(/^TREE: changed \(lib\/policy\.ts\)$/m); }); + // Value: protects=the fork-version bound reads the branch's own line by its position, so a base copy of the same text + // is still floored at the old version, and when the slot keeps its VERSION only the branch's own lines may move + // (GST-979); fails_when=stampOnlyLine matches the branch's lines by text alone or takes the old-version floor at equal + // versions; seam=lib/stamp-only.ts stampOnlyLine + test('the fork-version bound holds only at the branch line\'s own position, and a kept VERSION moves only the branch\'s own lines (GST-979)', () => { + const tpl = ["since: '{version}'"]; + const row = " { since: '1.2.3.0', id: 'x' },"; + const moved = row.replace('1.2.3.0', '1.2.5.0'); + const side = { fork: '1.2.3.0', added: new Set([row]), at: new Set([10]) }; + expect(stampOnlyLine(row, moved, '1.2.4.0', '1.2.5.0', tpl, side, 10)).toBe(true); + expect(stampOnlyLine(row, moved, '1.2.4.0', '1.2.5.0', tpl, side, 2)).toBe(false); + const mine = { fork: '1.2.3.0', added: new Set(["mine since: '1.2.3.0'"]), at: new Set([5]) }; + expect(stampOnlyLine("base since: '1.3.0.0'", "base since: '1.2.4.0'", '1.2.4.0', '1.2.4.0', tpl, mine, 1)).toBe(false); + expect(stampOnlyLine("mine since: '1.2.3.0'", "mine since: '1.2.4.0'", '1.2.4.0', '1.2.4.0', tpl, mine, 5)).toBe(true); + }); + test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => { const repo = fixture(); const g = gated(repo); From 5ffb49414092e6b7f238238f5b0a447090d60c83 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 18:19:02 +0000 Subject: [PATCH 02/14] fix: pre-landing review fixes (GST-980 carry reads the stamp file line by line at the branch's positions, and keeps its mode) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-receipt | 20 +++++++-- changelog.d/gst-979-980-stamp-positions.md | 4 +- docs/ship-policy.md | 2 +- test/restamp-mentions.test.ts | 48 ++++++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index c8aaa08b5..d6f026171 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -37,7 +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 { branchSides, stampOnlyLine, 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'; @@ -496,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; sides: ReadonlyArray; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[]; demoted: string[] } | undefined { +function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet; sides: ReadonlyArray; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[]; demoted: string[]; ownRows: 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; }; @@ -514,7 +514,19 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path) : [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product'))); const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p))); - return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted }; + // A demoted path whose from-to change is, line for line, a stamp on the branch's own rows (named by their position in + // `from`), a regular file with the reviewed mode at both heads (GST-980): never the whole-text version swap. + const ownRows = demoted.filter(p => { + const side = branch?.own(p); + const mode = (ref: string) => /^(100644|100755) blob /.exec(git(['ls-tree', ref, '--', p], repoRoot) ?? '')?.[1]; + const before = blobAt(from, p), after = blobAt(to, p), oldVersion = version(from), newVersion = version(to); + if (!side || before === null || after === null || !oldVersion || !newVersion || !mode(from) || mode(from) !== mode(to)) return false; + const was = before.split('\n'); + const now = after.split('\n'); + const lines = was.map((_, i) => i).filter(i => was[i] !== now[i]); + return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1)); + }); + return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted, ownRows }; } /** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */ @@ -662,7 +674,7 @@ function cmdCarry(rest: string[], json: boolean): number { for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); - if (cls === 'metadata' && spec.demoted.includes(p) && !movedBase.has(p)) stampOnly.push(p); + if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p); if (cls !== 'product') continue; const verdict = comparePath(repoRoot, sides, p); if (verdict === 'changed') changedProduct.push(p); diff --git a/changelog.d/gst-979-980-stamp-positions.md b/changelog.d/gst-979-980-stamp-positions.md index 2f941aa3e..2da444edb 100644 --- a/changelog.d/gst-979-980-stamp-positions.md +++ b/changelog.d/gst-979-980-stamp-positions.md @@ -4,11 +4,11 @@ - **`lib/stamp-only.ts`:** `BranchSide` carries the positions of the branch's lines, and `stampOnlyLine` takes the removed line's position. Only a line at a branch-added position gets the fork-version bound. When the version does not move, only such a line may change. - **`lib/restamp.ts`:** `parseUnifiedDiff` records each removed line's position on the old side, which the tree receipt, the train checks and finish's `stampedFiles` pass along. - **`lib/ship-receipt.ts`, `lib/ship-bin-common.ts`:** `stampOnlyText` and `stampedFiles` accept a kept VERSION claim for the branch's own rows and nothing else. -- **`bin/gstack-ship-receipt` carry:** a stamp file the branch changed as product, whose only change since the reviewed head is a stamp on its own rows and which the base did not touch, is left out of the product comparison. The carry records `a stamp on the branch's own rows ()` as the reason. A product edit anywhere else still refuses. +- **`bin/gstack-ship-receipt` carry:** a stamp file the branch changed as product, whose only change since the reviewed head is a stamp on its own rows and which the base did not touch, is left out of the product comparison. The carry records `a stamp on the branch's own rows ()` as the reason. It reads that file line by line. Only lines at the branch's own positions may move, and the file must keep its reviewed mode, so a version edit on a base line of the same file or a mode change still refuses, as does a product edit anywhere else. ### Tests - `test/tree-receipt-stamp-sensitive.test.ts`: the bound holds only at the branch line's position, and a kept VERSION refuses a base line moved down. -- `test/restamp-mentions.test.ts`: a hand move of a base row whose text the branch duplicates reads `TREE: changed`, and the carry follows the fork-version row stamp but still refuses a product edit beside it. +- `test/restamp-mentions.test.ts`: a hand move of a base row whose text the branch duplicates reads `TREE: changed`, and the carry follows the fork-version row stamp but still refuses a product edit beside it, a hand version edit on a base line of the same file, and a mode change. - `test/gstack-ship-receipt-docs.test.ts`, `test/ship-rereview-triggers.test.ts`: the carry classifier and finish accept the kept-claim row move and refuse a base row moved the same way. On main, all five new tests fail. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index d9fcabec6..630feb694 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1529,7 +1529,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 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. A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when the base did not touch that file and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980). +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. A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when every changed line of that file sits at a branch-added position, the file keeps its mode, the base did not touch it and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980). 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/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index 4c882b89e..68a546a9c 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -383,4 +383,52 @@ describe('a branch-added row left at the fork version after the base moved (GST- expect(changed.status).toBe(EXIT.refused); expect(changed.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); }); + + /** The GST-980 carry state: a receipt written at the claim 1.2.4.0 with the branch's own row, then the stamp to 1.2.5.0, uncommitted. */ + function carryAfterStamp(prefix: string, baseRows: string[], mine: string, stamped: string[]): { repo: FixtureRepo; body: string } { + const repo = makeFixtureRepo({ prefix, policy: policy(true) }); repos.push(repo); + write(repo, NOTICE, notice(baseRows)); + fs.rmSync(path.join(repo.work, 'package-lock.json')); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-carry'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + write(repo, NOTICE, notice([...baseRows, '', mine])); + repo.git('commit', '-qam', 'mine: a row of its own'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]); + expect(w.status, w.err).toBe(EXIT.ok); + expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); + expect(readFile(repo, NOTICE)).toBe(notice(stamped)); + return { repo, body }; + } + + // Value: protects=the carry leaves out only a stamp on the branch's own rows, never a version edit on a base line of + // the same file (GST-980 review); fails_when=the carry trusts the whole-text version swap, so a hand move of a base + // line's version mention rides along with the row stamp; seam=bin/gstack-ship-receipt productSpec ownRows + test('the carry refuses a hand version edit on a base line of a stamp file whose own row it follows (GST-980)', () => { + const min = "export const MIN = '1.2.4.0';"; + const { repo, body } = carryAfterStamp('restamp-carry-base-line-', [row('1.2.3.0', 'base_row'), min], row('1.2.4.0', 'mine'), + [row('1.2.3.0', 'base_row'), min, '', row('1.2.5.0', 'mine')]); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), min.replace('1.2.4.0', '1.2.5.0'), '', row('1.2.5.0', 'mine')])); + repo.git('commit', '-qam', 'stamp 1.2.5.0, and move the base pin by hand'); + const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(carried.status, carried.out).toBe(EXIT.refused); + expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + }); + + // Value: protects=a mode change rides on no stamp: the carry leaves out a stamp file only when its mode is the + // reviewed one (GST-980 review); fails_when=the left-out stamp file drops its mode line from both patch-ids; + // seam=bin/gstack-ship-receipt productSpec ownRows + test('the carry refuses a mode change on a stamp file whose own row it follows (GST-980)', () => { + const { repo, body } = carryAfterStamp('restamp-carry-mode-', [row('1.2.3.0', 'base_row')], row('1.2.3.0', 'mine'), + [row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]); + fs.chmodSync(path.join(repo.work, NOTICE), 0o755); + repo.git('commit', '-qam', 'stamp 1.2.5.0, and make the file executable'); + expect(repo.git('ls-tree', 'HEAD', '--', NOTICE)).toStartWith('100755 '); + const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(carried.status, carried.out).toBe(EXIT.refused); + expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + }); }); From ba52485a9626f0b295712c97a6e9b9a5ee21ff01 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 18:44:27 +0000 Subject: [PATCH 03/14] docs(changelog): GST-979/980 entry counts the seven tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gst-979-980-stamp-positions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/gst-979-980-stamp-positions.md b/changelog.d/gst-979-980-stamp-positions.md index 2da444edb..bbdd1294e 100644 --- a/changelog.d/gst-979-980-stamp-positions.md +++ b/changelog.d/gst-979-980-stamp-positions.md @@ -11,4 +11,4 @@ - `test/restamp-mentions.test.ts`: a hand move of a base row whose text the branch duplicates reads `TREE: changed`, and the carry follows the fork-version row stamp but still refuses a product edit beside it, a hand version edit on a base line of the same file, and a mode change. - `test/gstack-ship-receipt-docs.test.ts`, `test/ship-rereview-triggers.test.ts`: the carry classifier and finish accept the kept-claim row move and refuse a base row moved the same way. -On main, all five new tests fail. +On main, five of the seven new tests fail. The other two, the carry's refusals of a base-line version edit and a mode change, pass on main, which refuses every such carry; they fail on the broader first version of this fix. From 5d281a1d3e7680e5aadb1a152d2b89cff6415eaa Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 18:51:24 +0000 Subject: [PATCH 04/14] docs: the receipt carry follows a stamp on the branch's own rows (GST-980) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-policy.md | 1 + docs/troubleshooting.md | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 630feb694..4ad00c410 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1359,6 +1359,7 @@ receipt when the base moves: | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | | a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | +| a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows ()`) | **Stacked PRs (#3570).** A child PR's receipt is written against its parent branch, so its `base` names that branch and its `product_patch_id` is the diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1c0e96302..97167590f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5860,8 +5860,10 @@ retargeted stacked PR the parent branch and the base. conflict-only base merge, a test-only change, release metadata, a registered output the pregate's regen check proved, a rebase onto a covered tree with the recorded product diff, a stacked PR's retarget once its parent fully -landed on the base, or base context next to unchanged hunks after a clean -scoped review ([`RECEIPT_CARRY_SCOPED_REVIEW`](#receipt-carry-scoped-review)), which the +landed on the base, a stamp on the branch's own rows in a stamp file it also +changed as product (every changed line at a branch-added position, the mode +unchanged, the base not touching the file), or base context next to unchanged +hunks after a clean scoped review ([`RECEIPT_CARRY_SCOPED_REVIEW`](#receipt-carry-scoped-review)), which the `carry` object in the carried receipt records with the product patch-id and the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)). From f926ccb0c206ce9b1cf2387f3500558a20102510 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 18:55:55 +0000 Subject: [PATCH 05/14] docs: keep docs/ship-policy.md at the reviewed text; its GST-980 sentence already states the carry rule Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-policy.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 4ad00c410..630feb694 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1359,7 +1359,6 @@ receipt when the base moves: | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | | a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | -| a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows ()`) | **Stacked PRs (#3570).** A child PR's receipt is written against its parent branch, so its `base` names that branch and its `product_patch_id` is the From b85e4cef84ce9fe1e4ea33effbfd3ff0161f5472 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 19:02:18 +0000 Subject: [PATCH 06/14] Revert "docs: keep docs/ship-policy.md at the reviewed text; its GST-980 sentence already states the carry rule" This reverts commit f926ccb0c206ce9b1cf2387f3500558a20102510. --- docs/ship-policy.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 630feb694..4ad00c410 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1359,6 +1359,7 @@ receipt when the base moves: | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | | a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | +| a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows ()`) | **Stacked PRs (#3570).** A child PR's receipt is written against its parent branch, so its `base` names that branch and its `product_patch_id` is the From ead29adf42b090d1a8d853781799ced70b594ba7 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 19:47:18 +0000 Subject: [PATCH 07/14] fix(stamp): a declared placeholder restamped on the branch's own row is a stamp; the carry names HEAD's version (GST-1026, GST-1030) stamp_placeholders (default []) lets a repo declare literals such as TBD that its restamp replaces with the new version inside a stamp_patterns template. stampOnlyLine accepts that move only on a branch line named by position; finish's stampedFiles, the receipt carry and the tree receipt share it. The carried receipt's version is HEAD's, read the way write reads it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-receipt | 13 +- .../gst-1026-1030-stamp-placeholders.md | 16 +++ docs/ship-policy.md | 1 + lib/restamp.ts | 2 +- lib/ship-bin-common.ts | 9 +- lib/ship-policy.ts | 21 +++ lib/ship-receipt.ts | 6 +- lib/stamp-only.ts | 28 +++- lib/tree-receipt.ts | 9 +- test/gstack-ship-policy.test.ts | 27 +++- test/gstack-ship-receipt-docs.test.ts | 19 +++ test/restamp-mentions.test.ts | 126 ++++++++++++++++++ test/ship-rereview-triggers.test.ts | 32 +++++ test/tree-receipt-stamp-sensitive.test.ts | 26 ++++ 14 files changed, 314 insertions(+), 21 deletions(-) create mode 100644 changelog.d/gst-1026-1030-stamp-placeholders.md diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index d6f026171..ed17a062b 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -475,13 +475,13 @@ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | u return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p)); } -interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[] } +interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[]; placeholders: readonly string[] } function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules { const load = loadPolicyFromBase(repoRoot, base); const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy; const fragments = policy?.changelog_fragments?.dir.replace(/^(\.\/)+/, '').replace(/\/+$/, ''); const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs, fragments }; - return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [] }; + return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [], placeholders: policy?.stamp_placeholders ?? [] }; } /** @@ -508,7 +508,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const versions = new Map(); 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, side: a === from && b === to ? branch?.own(p) : undefined } : 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, placeholders: rules.placeholders, 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) @@ -524,7 +524,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const was = before.split('\n'); const now = after.split('\n'); const lines = was.map((_, i) => i).filter(i => was[i] !== now[i]); - return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1)); + return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1, rules.placeholders)); }); return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted, ownRows }; } @@ -748,7 +748,10 @@ function cmdCarry(rest: string[], json: boolean): number { depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } }; } // The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on. - const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField }; + // A receipt that records a version names HEAD's, read as write reads it: a restamp after a base merge moves it (GST-1030). + const version = receipt.version === undefined ? undefined : receiptVersion(repoRoot, head, base); + if (receipt.version !== undefined && version === undefined) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} has no readable release version, so the carried receipt cannot name one; fix the version source, then rerun carry`); + const rebound = { ...receipt, ...(version !== undefined ? { version } : {}), ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField }; const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); diff --git a/changelog.d/gst-1026-1030-stamp-placeholders.md b/changelog.d/gst-1026-1030-stamp-placeholders.md new file mode 100644 index 000000000..987ed1fd0 --- /dev/null +++ b/changelog.d/gst-1026-1030-stamp-placeholders.md @@ -0,0 +1,16 @@ +**A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1). + +### Added +- **`stamp_placeholders` policy key** (default `[]`, so nothing changes for a repo that does not set it): literal placeholders such as `["TBD"]`, at most 16, one line and 32 characters each, never `{version}` and never a version. It needs `min_gstack_version` at the release that introduced it. + +### Fixed +- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version. The built-in forms, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. +- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. + +### Tests +- `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. +- `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls. +- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product. +- `test/gstack-ship-policy.test.ts`: the key's default, floor and validation. + +The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 4ad00c410..aadc22f47 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -53,6 +53,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. | | `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. | | `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, ``); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. | +| `stamp_placeholders` | literal placeholders | `[]` | At most 16 one-line literals of at most 32 characters, never `{version}` and never a version (`["TBD"]`). On a line the branch added itself, a placeholder inside a `stamp_patterns` template (`since: 'TBD'` under `since: '{version}'`) may become that template at the new version, and finish's route, the receipt carry and the tree receipt read the change as a stamp. The built-in forms never take a placeholder, and neither does a base line, even one whose text duplicates a branch line. The writer does not fill placeholders: the repo's `restamp_command` does. A newer key: set `min_gstack_version` to the release `gstack-ship-policy validate` names. Empty accepts no placeholder. | | `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). | | `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. | | `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. | diff --git a/lib/restamp.ts b/lib/restamp.ts index be2c6fc23..9eba5f88e 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -313,7 +313,7 @@ export function branchAddedLines(cwd: string, since: string, files: string[], to return out; } -const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const VERSION_RE = '(?\\d+(?:\\.\\d+){1,3})(?![0-9.])'; /** * The built-in stamp forms as scanners, the version in the `v` group: `(was X)`, `since: X` and `vX`. diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index d1b8115d3..cfc5a3b9f 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -112,7 +112,8 @@ export function releasePaths(policy: Record | null, cwd: string, re * added since the merge base with origin/, and not added by the base since): after a lost slot and a merge of the * base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead, * the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). A branch line is - * named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980). + * named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980); on it a declared + * `stamp_placeholders` value may also become the new version inside a `stamp_patterns` template (GST-1026). */ export function stampedFiles(policy: Record | null, cwd: string, ref: string, base: string): string[] { const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : []; @@ -122,11 +123,13 @@ export function stampedFiles(policy: Record | null, cwd: string, re let after = ''; try { after = extractVersion(fs.readFileSync(path.join(cwd, before.rel), 'utf8'), before.rel); } catch { return []; } if (!after) return []; - const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : []; + const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []); + const templates = strings(policy?.stamp_patterns); + const placeholders = strings(policy?.stamp_placeholders); const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]); if (diff.status !== 0) return []; const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref); - const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at); + const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at, placeholders); 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]!, h.removedAt[i]!)))).map(f => f.path); } diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 4268b007e..044c75d49 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -61,6 +61,8 @@ export interface ShipPolicy { stamp_files: string[]; /** Literal stamp templates with one `{version}` slot, beside the built-in `(was X)`, `since: X`, `vX`; writer and classifier share them. */ stamp_patterns: string[]; + /** Placeholders (`TBD`) a restamp may replace with the new version inside a `stamp_patterns` template, on the branch's own lines only. */ + stamp_placeholders: string[]; /** The repo's own restamp, run first in the staging worktree with the target version; gstack's version writer is skipped. */ restamp_command: string | null; /** Migration numbering: `{ dir, id }` where `id` is a regex with one capture group over the file name. */ @@ -157,6 +159,7 @@ export const DEFAULT_POLICY: ShipPolicy = { maintainers: [], stamp_files: [], stamp_patterns: [], + stamp_placeholders: [], restamp_command: null, migrations: null, gate_command: null, @@ -188,6 +191,7 @@ export const POLICY_EXPLANATIONS: Record = { maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.', stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.', stamp_patterns: 'Literal templates with exactly one {version} slot (at most 16, 128 characters each), for example `"version": "{version}"` or `since: \'{version}\'`; the restamp writer and the tree receipt use them beside (was X), since: X and vX.', + stamp_placeholders: 'Literal placeholders (at most 16, one line, 32 characters each, never `{version}` or a version), for example `["TBD"]`: a line the branch added that reads a placeholder in a stamp_patterns template (`since: \'TBD\'`) may become that template at the new version, and finish, the receipt carry and the tree receipt read it as a stamp. Empty (default) accepts no placeholder.', restamp_command: 'The repo’s own restamp, run first in gstack-restamp’s staging worktree (a clean checkout of HEAD) with the target version as its argument and in GSTACK_RESTAMP_VERSION; gstack’s version writer is skipped, output confinement still applies. Needs a clean caller tree and --allow-repo-commands.', migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.', gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.', @@ -231,6 +235,7 @@ export const BUMP_NEVER_KEY_VERSION = '1.91.353.0'; export const BASE_RED_KEY_VERSION = '1.91.374.0'; /** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0'; +export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.0.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0', @@ -239,6 +244,7 @@ export const POLICY_KEY_INTRODUCED: Record = { train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION, changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION, base_red: BASE_RED_KEY_VERSION, + stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, }; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; @@ -270,6 +276,8 @@ const MAX_COMMAND = 512; const SHA_RE = /^[0-9a-f]{7,64}$/; const MAX_TEMPLATES = 16; const MAX_TEMPLATE = 128; +const MAX_PLACEHOLDERS = 16; +const MAX_PLACEHOLDER = 32; export const VERSION_SLOT = '{version}'; function hasDotDot(p: string): boolean { @@ -338,6 +346,18 @@ function templateErrors(value: unknown, out: PolicyError[]): void { }); } +function placeholderErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_placeholders', message: 'must be an array of literal placeholders' }); return; } + if (value.length > MAX_PLACEHOLDERS) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_placeholders', message: `more than ${MAX_PLACEHOLDERS} placeholders` }); + value.forEach((p, i) => { + const at = `stamp_placeholders[${i}]`; + if (typeof p !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (!p || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` }); + else if (p.includes(VERSION_SLOT)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} must not contain ${VERSION_SLOT}` }); + else if (parseVersion(p)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} is a version, not a placeholder` }); + }); +} + function migrationErrors(value: unknown, out: PolicyError[]): void { const m = value as Record | null; if (!m || typeof m !== 'object' || Array.isArray(m)) { out.push({ code: 'POLICY_INVALID', path: 'migrations', message: 'must be { dir, id } or null' }); return; } @@ -480,6 +500,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = if ('release_outputs' in obj) listErrors('release_outputs', obj.release_outputs, errors, globError); if ('stamp_files' in obj) listErrors('stamp_files', obj.stamp_files, errors, globError); if ('stamp_patterns' in obj) templateErrors(obj.stamp_patterns, errors); + if ('stamp_placeholders' in obj) placeholderErrors(obj.stamp_placeholders, errors); if ('restamp_command' in obj && obj.restamp_command !== null) { const t = obj.restamp_command; if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' }); diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 3866df4de..98e884dae 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -188,9 +188,9 @@ 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. + * `package.json`) and the base policy's `stamp_patterns` templates and `stamp_placeholders`. */ -export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide } +export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide; placeholders?: readonly string[] } /** * A policy-named stamp path's change is a stamp when the new text reads as the @@ -213,7 +213,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 ?? [], change.side, i + 1)); + return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side, i + 1, change.placeholders ?? [])); } /** diff --git a/lib/stamp-only.ts b/lib/stamp-only.ts index bbb87dc66..a5f0b3179 100644 --- a/lib/stamp-only.ts +++ b/lib/stamp-only.ts @@ -4,7 +4,8 @@ * 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 { branchAddedLines, escapeRegExp, forkVersion, rewriteStamps, stampMentions } from './restamp'; +import { VERSION_SLOT } from './ship-policy'; import { cmpVersion, parseVersion, versionWidth } from './version-source'; /** @@ -26,12 +27,17 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source'; * 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. When the * version does not move (a slot that keeps its claimed VERSION), only such a - * branch line may change: the stage moves nothing else then. + * branch line may change: the stage moves nothing else then. On such a branch + * line a declared placeholder (`stamp_placeholders`, GST-1026: gbrain's + * `since: 'TBD'`) may also become the new version, but only inside a + * `stamp_patterns` template, never a built-in form: the line rewritten that way + * must be the added line, with at least one rewrite. */ -export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number): boolean { +export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number, placeholders: readonly string[] = []): boolean { if (removed === added) return false; if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true; - return !!side && at !== undefined && side.at.has(at) && side.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates); + if (!side || at === undefined || !side.at.has(at) || !side.added.has(removed)) return false; + return stampOnlyWithin(removed, added, side.fork, newV, templates) || placeholderOnly(removed, added, newV, templates, placeholders); } /** @@ -64,6 +70,20 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal }; } +/** Each declared placeholder rewritten to `newV` inside each template (a bare edge of the slot at a word boundary): the added line, with at least one rewrite. */ +function placeholderOnly(removed: string, added: string, newV: string, templates: readonly string[], placeholders: readonly string[]): boolean { + let text = removed; + let count = 0; + for (const t of templates) { + const [pre, post] = t.split(VERSION_SLOT) as [string, string]; + for (const p of placeholders) { + const re = new RegExp(`${escapeRegExp(pre)}${pre ? '' : '(? { count++; return pre + newV + post; }); + } + } + return count > 0 && text === added; +} + function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean { const floor = parseVersion(oldV); const width = versionWidth(oldV); diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index f5d46b053..0611d6074 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -78,15 +78,16 @@ 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`, bounded by the fork version on the branch's own lines). + * moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines, where a + * declared placeholder may also move inside a stamp_patterns template, GST-1026). */ -function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null { +function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide, placeholders: readonly string[] = []): { 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, side, h.removedAt[i])) 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, h.removedAt[i], placeholders)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; } } return null; @@ -130,7 +131,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; 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); + const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own, policy.stamp_placeholders); 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/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 025b1bb57..66016e1a9 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; import { EXIT } from '../lib/headless-artifacts'; -import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy'; +import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, STAMP_PLACEHOLDERS_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy'; import { fillDue } from '../lib/ship-queue-train'; import { cmpVersion, parseVersion } from '../lib/version-source'; import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; @@ -63,6 +63,31 @@ describe('validatePolicy (schema + containment, pure)', () => { expect(codes({ preregistration_shas: ['not-a-sha'] })).toEqual(['POLICY_CONTAINMENT:preregistration_shas[0]']); }); + // Value: protects=stamp_placeholders defaults to none, needs its reader floor, and admits only short one-line literals + // that are neither the {version} slot nor a version (GST-1026); fails_when=a non-default value validates without + // min_gstack_version, or a version, a slot, a multi-line or an oversized entry is accepted; seam=validatePolicy + test('stamp_placeholders: empty by default, needs its floor, and admits only short literal placeholders (GST-1026)', () => { + const codes = (p: unknown) => validatePolicy(p).map(e => `${e.code}:${e.path}`); + const at = (stamp_placeholders: unknown) => codes({ stamp_placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION }); + expect(DEFAULT_POLICY.stamp_placeholders).toEqual([]); + expect(POLICY_KEY_INTRODUCED.stamp_placeholders).toBe(STAMP_PLACEHOLDERS_KEY_VERSION); + expect(codes({ stamp_placeholders: [] })).toEqual([]); + expect(codes({ stamp_placeholders: ['TBD'] })).toEqual(['POLICY_INVALID:stamp_placeholders']); + expect(minimumReader({ stamp_placeholders: ['TBD'] })).toBe(STAMP_PLACEHOLDERS_KEY_VERSION); + expect(at(['TBD', 'UNRELEASED'])).toEqual([]); + expect(at('TBD')).toEqual(['POLICY_INVALID:stamp_placeholders']); + expect(at([7])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(Array.from({ length: 17 }, (_, i) => `P${i}`))).toEqual(['POLICY_CONTAINMENT:stamp_placeholders']); + expect(at(['x'.repeat(33)])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at(['x'.repeat(32)])).toEqual([]); + expect(at(['TB\nD'])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at([''])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at(['{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(['next-{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(['1.2.3.0'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(['0.60.553'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + }); + // Value: protects=bump never is refused without its floor (an older gstack names the cause) and next to any policy that stamps a version at every merge, reports its reader floor, and prints its Step 12 consequence; fails_when=never validates bare, or beside stamp-at-merge, train or changelog_fragments; // why_new=GST-380 added the value to an older key, which the per-key floor check skips; seam=validatePolicy, minimumReader, shipConsequences; no_claim=Step 12's behavior (test/ship-release.test.ts) test('bump never needs its floor and rules out every policy that stamps at merge', () => { diff --git a/test/gstack-ship-receipt-docs.test.ts b/test/gstack-ship-receipt-docs.test.ts index db89a68fb..167aafb20 100644 --- a/test/gstack-ship-receipt-docs.test.ts +++ b/test/gstack-ship-receipt-docs.test.ts @@ -248,6 +248,25 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit expect(stampOnlyText({ before, after: lines(key('1.2.3.0'), key('1.2.4.0')), oldVersion: '1.2.4.0', newVersion: '1.2.4.0', templates })).toBe(false); }); + // Value: protects=the carry classifier reads a declared placeholder moved to the new version on the branch's own row + // as a stamp, through the same predicate as finish and the tree receipt (GST-1026); fails_when=stampOnlyText ignores + // the policy's placeholders, or accepts an undeclared one or a base row; seam=lib/ship-receipt.ts stampOnlyText + test('stampOnlyText reads a declared placeholder moved on the branch\'s own row as a stamp, and nothing else (GST-1026)', () => { + const templates = ["since: '{version}'"]; + const row = (v: string, id: string) => ` { since: '${v}', id: '${id}' },`; + const text = (...rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n'); + const before = text(row('TBD', 'base'), row('TBD', 'mine')); + const side = { fork: '0.60.552.0', added: new Set([row('TBD', 'mine')]), at: new Set([3]) }; + const change = (after: string, extra: Record = {}) => stampOnlyText({ before, after, oldVersion: '0.60.552.0', newVersion: '0.60.553.0', templates, side, placeholders: ['TBD'], ...extra }); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')))).toBe(true); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { oldVersion: '0.60.553.0' })).toBe(true); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: [] })).toBe(false); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: ['WIP'] })).toBe(false); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { templates: [] })).toBe(false); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { side: undefined })).toBe(false); + expect(change(text(row('0.60.553.0', 'base'), row('TBD', 'mine')))).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: '' }); diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index 68a546a9c..fed37e729 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -14,6 +14,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { rewriteStamps, tentativeMentions } from '../lib/restamp'; +import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy'; +import { extractReceipt } from '../lib/ship-receipt'; import type { GitFixtureTemplate } from './helpers/git-fixture-template'; import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; @@ -432,3 +434,127 @@ describe('a branch-added row left at the fork version after the base moved (GST- expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); }); }); + +describe('declared placeholders and the receipt carry (GST-1026, GST-1030)', () => { + /** gbrain's policy (GST-1026): the since: template, and TBD declared as a placeholder when `declared`. */ + const placeholderPolicy = (declared: boolean) => ({ ...receiptPolicy(true), ...(declared ? { stamp_placeholders: ['TBD'], min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) }); + + /** A branch row written as since: 'TBD' at the claim 1.2.4.0, then the stamp to 1.2.5.0 with the row restamped by hand (gbrain's release:restamp). */ + function tbdStamp(prefix: string, pol: Record, ownRow: string): { repo: FixtureRepo; gated: string } { + const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo); + write(repo, NOTICE, notice([row('TBD', 'base_row')])); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-tbd'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + write(repo, NOTICE, notice([row('TBD', 'base_row'), '', ownRow])); + repo.git('commit', '-qam', 'mine: a TBD row'); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); + return { repo, gated }; + } + + // Value: protects=the tree receipt (stamp, verify, gate-ahead replay) accepts gbrain's restamp of the branch's own + // since: 'TBD' row when the policy declares TBD, and refuses it undeclared, on a base row, or on a base row whose text + // the branch duplicates (GST-1026); fails_when=classifyFile reads the TBD move as a hand edit, or accepts it off the + // branch's own rows; seam=lib/tree-receipt.ts stampOnlyHunks + test('the tree receipt accepts a declared TBD row restamped on the branch, and refuses the same move on a base row (GST-1026)', () => { + const own = tbdStamp('restamp-tbd-receipt-', placeholderPolicy(true), row('TBD', 'mine')); + write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine'))); + own.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row'); + const accepted = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']); + expect(accepted.out, accepted.err).toContain('TREE: same-modulo-stamps'); + write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'base_row'), row('1.2.5.0', 'base_row'))); + own.repo.git('commit', '-qam', 'move the base TBD row by hand'); + const base = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']); + expect(base.out, base.err).toContain('TREE: changed'); + expect(base.out).toContain("id: 'base_row'"); + }); + + test('the tree receipt refuses an undeclared TBD row restamped on the branch (GST-1026)', () => { + const undeclared = tbdStamp('restamp-tbd-undeclared-', placeholderPolicy(false), row('TBD', 'mine')); + write(undeclared.repo, NOTICE, readFile(undeclared.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine'))); + undeclared.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row'); + const refused = runBin(undeclared.repo, 'gstack-tree-receipt', ['--gated', undeclared.gated, '--base', 'main']); + expect(refused.out, refused.err).toContain('TREE: changed'); + }); + + test('the tree receipt refuses a restamp of the base copy of a TBD row the branch duplicates (GST-1026)', () => { + const dup = tbdStamp('restamp-tbd-dup-', placeholderPolicy(true), row('TBD', 'base_row')); + write(dup.repo, NOTICE, notice([row('1.2.5.0', 'base_row'), '', row('TBD', 'base_row')])); + dup.repo.git('commit', '-qam', 'move the base copy of the duplicated TBD row'); + const copy = runBin(dup.repo, 'gstack-tree-receipt', ['--gated', dup.gated, '--base', 'main']); + expect(copy.out, copy.err).toContain('TREE: changed'); + }); + + /** A receipt written at the claim 1.2.4.0 after `own` changed the branch, then the stamp to 1.2.5.0, uncommitted. */ + function receiptThenStamp(prefix: string, pol: Record, own: (repo: FixtureRepo) => void): { repo: FixtureRepo; body: string } { + const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo); + write(repo, NOTICE, notice([row('TBD', 'base_row')])); + fs.rmSync(path.join(repo.work, 'package-lock.json')); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-carry'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + own(repo); + repo.git('add', '-A'); repo.git('commit', '-qm', 'mine'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]); + expect(w.status, w.err).toBe(EXIT.ok); + expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); + return { repo, body }; + } + const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + + // Value: protects=the receipt carry follows gbrain's restamp of the branch's own since: 'TBD' row when the policy + // declares TBD, and refuses it undeclared (GST-1026); fails_when=productSpec's own-row check ignores the policy's + // placeholders and refuses RECEIPT_CARRY_PRODUCT_CHANGED; seam=bin/gstack-ship-receipt productSpec ownRows + test.each([['declared', true], ['undeclared', false]])('the carry and a TBD row restamped on the branch: %s', (_, declared) => { + const { repo, body } = receiptThenStamp(`restamp-tbd-carry-${declared}-`, placeholderPolicy(declared), r => write(r, NOTICE, notice([row('TBD', 'base_row'), '', row('TBD', 'mine')]))); + write(repo, NOTICE, readFile(repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine'))); + repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row'); + const carried = carry(repo, body); + if (declared) expect(carried.out, carried.err).toContain('CARRY: carried'); + else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]); + }); + + // Value: protects=a carried receipt names the head's version, the one write would record there, after a main merge + // and a restamp (GST-1030 point 1, GGST-1); fails_when=carryReceipt copies version from the old receipt; + // seam=bin/gstack-ship-receipt cmdCarry + test('the carried receipt records the head\'s version after a main merge and a restamp (GST-1030)', () => { + const { repo, body } = receiptThenStamp('restamp-carry-version-', policy(true), r => write(r, 'src.ts', 'export const x = 0;\n')); + repo.git('reset', '-q', '--hard'); + expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n'); + makePr(repo, 'pr-other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-carry'); + mergeMain(repo); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'restamp after the lost slot'); + expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n'); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.4.0'); + const carried = carry(repo, body); + expect(carried.out, carried.err).toContain('CARRY: carried'); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.5.0'); + }); + + // Value: protects=a version-only restamp of EVIDENCE.md is release metadata to the carry when the policy declares it in + // stamp_files, and product when it does not (GST-1030 point 2, GGST-1); fails_when=the carry counts the declared + // file's stamp as a product change (RECEIPT_CARRY_PRODUCT_CHANGED), or lets an undeclared file's version edit ride; + // seam=bin/gstack-ship-receipt productSpec, lib/ship-receipt.ts stampOnlyText + test.each([['declared in stamp_files', true], ['undeclared', false]])('the carry and a version-only restamp of EVIDENCE.md: %s (GST-1030)', (_, declared) => { + const base = policy(true); + const pol = declared ? { ...base, stamp_files: [...base.stamp_files, 'EVIDENCE.md'] } : base; + const { repo, body } = receiptThenStamp(`restamp-carry-evidence-${declared}-`, pol, r => write(r, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.4.0 with the free suite\n')); + if (!declared) write(repo, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.5.0 with the free suite\n'); + expect(readFile(repo, 'EVIDENCE.md')).toBe('# Evidence\n\nVerified on v1.2.5.0 with the free suite\n'); + repo.git('commit', '-qam', 'stamp 1.2.5.0'); + const carried = carry(repo, body); + if (declared) expect(carried.out, carried.err).toContain('CARRY: carried'); + else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]); + }); +}); + diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 63bc87850..c78767a18 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { comparePath } from '../lib/ship-patch'; +import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy'; import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt'; import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; @@ -266,6 +267,37 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => const base = kept([['0.9.0.0', 'old'], ['1.0.1.0', 'base'], ['1.0.0.0', 'mine']]); expect([base.status, lineOf(base.stdout, 'STOP')]).toEqual([3, behaviorStop]); }); + + // Value: protects=gbrain's restamp of the PR's own `since: 'TBD'` row to the new version routes as metadata when the + // policy declares TBD in stamp_placeholders (GST-1026, gbrain #8444); an undeclared placeholder, a base row moved the + // same way, a duplicated base copy, or a line no stamp_pattern matches still stops; fails_when=stampedFiles reads the + // move as behavior (gbrain #8444) or accepts it off the branch's own rows; seam=lib/ship-bin-common.ts stampedFiles + test('a declared placeholder on the branch row is metadata; undeclared, a base row or no template stops (GST-1026)', () => { + const declared = (placeholders: string[] | undefined, templates = ["since: '{version}'"]) => JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: templates, + ...(placeholders ? { stamp_placeholders: placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) }); + const restamped = (pol: string, baseRows: [string, string][], ownRows: [string, string][], rows: [string, string][]) => { + const f = fx({ '.gstack/ship-policy.json': pol, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(...baseRows) }, + { VERSION: '1.0.1.0\n', 'src/notice.ts': notice(...baseRows, ...ownRows) }); + const reviewed = wtree(f); + const post = snapshot(f); + f.write({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(...rows) }); f.commit('restamp: TBD rows to 1.0.2.0'); + return finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]); + }; + const base: [string, string][] = [['0.9.0.0', 'old'], ['TBD', 'base']]; + const metadata = / behavior=0 docs=0 metadata=2 tests=0 /; + const own = restamped(declared(['TBD']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect(lineOf(own.stdout, 'ROUTE'), own.stdout).toEqual(expect.stringMatching(metadata)); + const undeclared = restamped(declared(undefined), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect([undeclared.status, lineOf(undeclared.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const other = restamped(declared(['WIP']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect([other.status, lineOf(other.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const baseRow = restamped(declared(['TBD']), base, [['TBD', 'mine']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['1.0.2.0', 'mine']]); + expect([baseRow.status, lineOf(baseRow.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const dup = restamped(declared(['TBD']), base, [['TBD', 'base']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['TBD', 'base']]); + expect([dup.status, lineOf(dup.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const noTemplate = restamped(declared(['TBD'], ['version: {version}']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect([noTemplate.status, lineOf(noTemplate.stdout, 'STOP')]).toEqual([3, behaviorStop]); + }); }); describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => { diff --git a/test/tree-receipt-stamp-sensitive.test.ts b/test/tree-receipt-stamp-sensitive.test.ts index e9c56e660..848704e63 100644 --- a/test/tree-receipt-stamp-sensitive.test.ts +++ b/test/tree-receipt-stamp-sensitive.test.ts @@ -121,6 +121,32 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', () expect(stampOnlyLine("mine since: '1.2.3.0'", "mine since: '1.2.4.0'", '1.2.4.0', '1.2.4.0', tpl, mine, 5)).toBe(true); }); + // Value: protects=a declared placeholder on the branch's own row moves to the new version as a stamp, only inside a + // stamp_patterns template (gbrain's `since: 'TBD'` row, GST-1026); fails_when=stampOnlyLine reads the move as a hand + // edit, or accepts an undeclared placeholder, a base row, a duplicated base line, a built-in form or a line no + // template matches; seam=lib/stamp-only.ts stampOnlyLine placeholders + test('a declared placeholder on a branch row moves to the new version inside a stamp_patterns template, and nowhere else (GST-1026)', () => { + const tpl = ["since: '{version}'"]; + const row = " { since: 'TBD', id: 'mine' },"; + const moved = " { since: '0.60.553.0', id: 'mine' },"; + const side = (r: string, at = 10) => ({ fork: '0.60.552.0', added: new Set([r]), at: new Set([at]) }); + const line = (r: string, a: string, opts: { templates?: string[]; at?: number; placeholders?: string[]; oldV?: string } = {}) => + stampOnlyLine(r, a, opts.oldV ?? '0.60.552.0', '0.60.553.0', opts.templates ?? tpl, side(r), opts.at ?? 10, opts.placeholders ?? ['TBD']); + expect(line(row, moved)).toBe(true); + expect(line(row, moved, { oldV: '0.60.553.0' })).toBe(true); + expect(line(row, moved, { placeholders: [] })).toBe(false); + expect(line(row, moved, { placeholders: ['WIP'] })).toBe(false); + expect(line(" { since: 'WIP', id: 'mine' },", moved)).toBe(false); + expect(line(row, moved, { at: 2 })).toBe(false); + expect(stampOnlyLine(row, moved, '0.60.552.0', '0.60.553.0', tpl, undefined, undefined, ['TBD'])).toBe(false); + expect(stampOnlyLine(row, moved, '0.60.552.0', '0.60.553.0', tpl, side(" { since: 'TBD', id: 'other' },"), 10, ['TBD'])).toBe(false); + expect(line(row, moved, { templates: [] })).toBe(false); + expect(line(" { release: 'TBD', id: 'mine' },", " { release: '0.60.553.0', id: 'mine' },")).toBe(false); + expect(line(' { since: TBD, id: mine },', ' { since: 0.60.553.0, id: mine },')).toBe(false); + expect(line(row, " { since: '0.60.553.0', id: 'yours' },")).toBe(false); + expect(line(row, " { since: '0.60.554.0', id: 'mine' },")).toBe(false); + }); + test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => { const repo = fixture(); const g = gated(repo); From 1b02200e20385ae38f3453c4b7e6a2c540de538b Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 20:40:49 +0000 Subject: [PATCH 08/14] fix: pre-landing review fixes (carry keeps a write --version receipt's version without a version source; a placeholder fill composes with a stamp move; a bare {version} template and a blank placeholder take no placeholder) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-receipt | 4 ++-- lib/ship-policy.ts | 2 +- lib/stamp-only.ts | 16 ++++++++++------ test/gstack-ship-policy.test.ts | 1 + test/gstack-ship-receipt-docs.test.ts | 19 +++++++++++++++++++ test/tree-receipt-stamp-sensitive.test.ts | 4 ++++ 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index ed17a062b..056669aae 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -749,8 +749,8 @@ function cmdCarry(rest: string[], json: boolean): number { } // The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on. // A receipt that records a version names HEAD's, read as write reads it: a restamp after a base merge moves it (GST-1030). - const version = receipt.version === undefined ? undefined : receiptVersion(repoRoot, head, base); - if (receipt.version !== undefined && version === undefined) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} has no readable release version, so the carried receipt cannot name one; fix the version source, then rerun carry`); + // A head with no version source keeps the recorded one (write --version named it). + const version = receipt.version === undefined ? undefined : (receiptVersion(repoRoot, head, base) ?? receipt.version); const rebound = { ...receipt, ...(version !== undefined ? { version } : {}), ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField }; const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 044c75d49..8c56c275d 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -352,7 +352,7 @@ function placeholderErrors(value: unknown, out: PolicyError[]): void { value.forEach((p, i) => { const at = `stamp_placeholders[${i}]`; if (typeof p !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } - if (!p || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` }); + if (!p.trim() || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` }); else if (p.includes(VERSION_SLOT)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} must not contain ${VERSION_SLOT}` }); else if (parseVersion(p)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} is a version, not a placeholder` }); }); diff --git a/lib/stamp-only.ts b/lib/stamp-only.ts index a5f0b3179..53b2b5231 100644 --- a/lib/stamp-only.ts +++ b/lib/stamp-only.ts @@ -30,14 +30,17 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source'; * branch line may change: the stage moves nothing else then. On such a branch * line a declared placeholder (`stamp_placeholders`, GST-1026: gbrain's * `since: 'TBD'`) may also become the new version, but only inside a - * `stamp_patterns` template, never a built-in form: the line rewritten that way - * must be the added line, with at least one rewrite. + * `stamp_patterns` template with literal text around its slot, never a built-in + * form: with at least one such rewrite, the line must then be the added line, + * or one whose remaining stamp-form moves pass the fork-version bound. */ export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number, placeholders: readonly string[] = []): boolean { if (removed === added) return false; if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true; if (!side || at === undefined || !side.at.has(at) || !side.added.has(removed)) return false; - return stampOnlyWithin(removed, added, side.fork, newV, templates) || placeholderOnly(removed, added, newV, templates, placeholders); + if (stampOnlyWithin(removed, added, side.fork, newV, templates)) return true; + const filled = fillPlaceholders(removed, newV, templates, placeholders); + return filled.count > 0 && (filled.text === added || stampOnlyWithin(filled.text, added, side.fork, newV, templates)); } /** @@ -70,18 +73,19 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal }; } -/** Each declared placeholder rewritten to `newV` inside each template (a bare edge of the slot at a word boundary): the added line, with at least one rewrite. */ -function placeholderOnly(removed: string, added: string, newV: string, templates: readonly string[], placeholders: readonly string[]): boolean { +/** Each declared placeholder rewritten to `newV` inside each template with literal text around its slot (a bare edge at a word boundary). */ +function fillPlaceholders(removed: string, newV: string, templates: readonly string[], placeholders: readonly string[]): { text: string; count: number } { let text = removed; let count = 0; for (const t of templates) { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; + if (!pre && !post) continue; for (const p of placeholders) { const re = new RegExp(`${escapeRegExp(pre)}${pre ? '' : '(? { count++; return pre + newV + post; }); } } - return count > 0 && text === added; + return { text, count }; } function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean { diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 66016e1a9..e290256a8 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -82,6 +82,7 @@ describe('validatePolicy (schema + containment, pure)', () => { expect(at(['x'.repeat(32)])).toEqual([]); expect(at(['TB\nD'])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); expect(at([''])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at([' '])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); expect(at(['{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); expect(at(['next-{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); expect(at(['1.2.3.0'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); diff --git a/test/gstack-ship-receipt-docs.test.ts b/test/gstack-ship-receipt-docs.test.ts index 167aafb20..03213266c 100644 --- a/test/gstack-ship-receipt-docs.test.ts +++ b/test/gstack-ship-receipt-docs.test.ts @@ -53,6 +53,25 @@ describe('E18: a repository without a version source or CHANGELOG', () => { expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none'); }); + // Value: protects=a receipt write --version named in a repository with no version source keeps that version through a + // carry (GST-1030 review); fails_when=the carry refuses because HEAD has no readable version, or drops the version; + // seam=bin/gstack-ship-receipt cmdCarry + test('a carry keeps the version write --version named when HEAD has no version source (GST-1030)', () => { + const repo = fixture(); + for (const f of ['VERSION', 'package.json', 'package-lock.json']) fs.rmSync(path.join(repo.work, f)); + repo.git('add', '-A'); repo.git('commit', '-qm', 'changelog only'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-unversioned'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--version', '2.0.0', '--body', body]).status).toBe(EXIT.ok); + fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'test', 'a.test.ts'), 'test a\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'a test-only change'); + const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(carried.out, carried.err).toContain('CARRY: carried'); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('2.0.0'); + }); + test('a repository with a version source keeps its version, and one with only a CHANGELOG records no version rather than "none"', () => { const repo = fixture(); const w = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']); diff --git a/test/tree-receipt-stamp-sensitive.test.ts b/test/tree-receipt-stamp-sensitive.test.ts index 848704e63..033f4ea5b 100644 --- a/test/tree-receipt-stamp-sensitive.test.ts +++ b/test/tree-receipt-stamp-sensitive.test.ts @@ -145,6 +145,10 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', () expect(line(' { since: TBD, id: mine },', ' { since: 0.60.553.0, id: mine },')).toBe(false); expect(line(row, " { since: '0.60.553.0', id: 'yours' },")).toBe(false); expect(line(row, " { since: '0.60.554.0', id: 'mine' },")).toBe(false); + const both = " { since: 'TBD', note: 'Added in v0.60.552.0' },"; + expect(line(both, " { since: '0.60.553.0', note: 'Added in v0.60.553.0' },")).toBe(true); + expect(line(both, " { since: '0.60.553.0', note: 'Added in v0.60.551.0' },")).toBe(false); + expect(line(' // TBD: tidy', ' // 0.60.553.0: tidy', { templates: ['{version}'] })).toBe(false); }); test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => { From 666efd35ad909bee7a382293a5f196f587ddd8f0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 21:09:42 +0000 Subject: [PATCH 09/14] docs(changelog): GST-1026/1030 fragment names the review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gst-1026-1030-stamp-placeholders.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/gst-1026-1030-stamp-placeholders.md b/changelog.d/gst-1026-1030-stamp-placeholders.md index 987ed1fd0..09a33dbc5 100644 --- a/changelog.d/gst-1026-1030-stamp-placeholders.md +++ b/changelog.d/gst-1026-1030-stamp-placeholders.md @@ -4,8 +4,8 @@ - **`stamp_placeholders` policy key** (default `[]`, so nothing changes for a repo that does not set it): literal placeholders such as `["TBD"]`, at most 16, one line and 32 characters each, never `{version}` and never a version. It needs `min_gstack_version` at the release that introduced it. ### Fixed -- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version. The built-in forms, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. -- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. +- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. +- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded. ### Tests - `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. From 5923767db759ab3dfb9f4f35e7b1ccc082f65d98 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 22:30:45 +0000 Subject: [PATCH 10/14] fix(ship-receipt): carry masks only package.json version on a pin-bump PR (GST-1030, GBEV-3) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-receipt | 10 +++++++ .../gst-1026-1030-stamp-placeholders.md | 3 +- test/restamp-mentions.test.ts | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 056669aae..e257edd7e 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -675,6 +675,16 @@ function cmdCarry(rest: string[], json: boolean): number { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p); + if (cls === 'metadata' && spec.demoted.includes(p) && movedBase.has(p) && /(^|\/)package\.json$/.test(p)) { + const masked = Object.values(sides).map(ref => { + const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); + try { const { version: _, ...rest } = JSON.parse(r.status === 0 ? r.stdout : 'null') ?? {}; return JSON.stringify(rest); } catch { return null; } + }); + const [rb, rv, cb, cv] = masked; + if (masked.some(m => m === null) || rv !== cv || rb !== cb) changedProduct.push(p); + else stampOnly.push(p); + continue; + } if (cls !== 'product') continue; const verdict = comparePath(repoRoot, sides, p); if (verdict === 'changed') changedProduct.push(p); diff --git a/changelog.d/gst-1026-1030-stamp-placeholders.md b/changelog.d/gst-1026-1030-stamp-placeholders.md index 09a33dbc5..31ddbd11f 100644 --- a/changelog.d/gst-1026-1030-stamp-placeholders.md +++ b/changelog.d/gst-1026-1030-stamp-placeholders.md @@ -6,11 +6,12 @@ ### Fixed - **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. - **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded. +- **`bin/gstack-ship-receipt` carry, pin-bump PRs:** a `package.json` the branch changed as product (a dependency pin) that both main and a restamp moved since review is compared with only its `version` field masked, at all four sides. The receipt carries when nothing else in the file moved, and still refuses when the pin or any other field changed (GBEV-3, gbrain-evals #306). ### Tests - `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. - `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls. -- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product. +- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product; a pin-bump PR's receipt carries after a main merge and a restamp, and refuses when the pin moved since review. - `test/gstack-ship-policy.test.ts`: the key's default, floor and validation. The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it. diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index fed37e729..7bc14c5b2 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -541,6 +541,35 @@ describe('declared placeholders and the receipt carry (GST-1026, GST-1030)', () expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.5.0'); }); +/** package.json with its dependency pin set to `pin`, every other field kept. */ + const pinned = (repo: FixtureRepo, pin: string) => { + const pkg = JSON.parse(readFile(repo, 'package.json')); + pkg.dependencies = { ...pkg.dependencies, 'left-pad': pin }; + write(repo, 'package.json', JSON.stringify(pkg, null, 2) + '\n'); + }; + + // Value: protects=a pin-bump PR's receipt carries across a main merge and a restamp when package.json's only change since + // review is its version field, and refuses when the pin itself moved (GST-1030, GBEV-3, gbrain-evals #306); + // fails_when=the carry counts the PR's package.json as product and refuses RECEIPT_CARRY_PRODUCT_CHANGED on a version-only + // move, or lets a changed dependency pin ride; seam=bin/gstack-ship-receipt cmdCarry walk + test.each([['the pin unchanged since review', false], ['the pin moved since review', true]])('the carry and a pin-bump PR after a main merge: %s (GST-1030)', (_, moved) => { + const { repo, body } = receiptThenStamp(`restamp-carry-pin-${moved}-`, policy(true), r => pinned(r, '1.3.0')); + repo.git('reset', '-q', '--hard'); + makePr(repo, 'pr-other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-carry'); + mergeMain(repo); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + if (moved) pinned(repo, '1.3.1'); + repo.git('commit', '-qam', 'restamp after the lost slot'); + expect(JSON.parse(readFile(repo, 'package.json')).version).toBe('1.2.5'); + const carried = carry(repo, body); + if (moved) expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]); + else expect(carried.out, carried.err).toContain('CARRY: carried'); + }); + // Value: protects=a version-only restamp of EVIDENCE.md is release metadata to the carry when the policy declares it in // stamp_files, and product when it does not (GST-1030 point 2, GGST-1); fails_when=the carry counts the declared // file's stamp as a product change (RECEIPT_CARRY_PRODUCT_CHANGED), or lets an undeclared file's version edit ride; From 20a525a3cdacf51cbd33efb3cacb722d6a92367b Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 02:29:49 +0000 Subject: [PATCH 11/14] refactor(ship-policy): stamp key validation moves to lib/ship-policy-stamps.ts for the module-size ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-policy-stamps.ts | 35 +++++++++++++++++++++++++++++++++++ lib/ship-policy.ts | 30 ++---------------------------- 2 files changed, 37 insertions(+), 28 deletions(-) create mode 100644 lib/ship-policy-stamps.ts diff --git a/lib/ship-policy-stamps.ts b/lib/ship-policy-stamps.ts new file mode 100644 index 000000000..6e0b144da --- /dev/null +++ b/lib/ship-policy-stamps.ts @@ -0,0 +1,35 @@ +/** + * Validation of the stamp policy keys (stamp_patterns templates and stamp_placeholders), split out of ship-policy.ts + * to keep that module under the module-size ratchet. + */ +import type { PolicyError } from './ship-policy'; +import { parseVersion } from './version-source'; + +const MAX_TEMPLATES = 16; +const MAX_TEMPLATE = 128; +const MAX_PLACEHOLDERS = 16; +const MAX_PLACEHOLDER = 32; +export const VERSION_SLOT = '{version}'; + +export function templateErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; } + if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` }); + value.forEach((t, i) => { + const at = `stamp_patterns[${i}]`; + if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` }); + else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` }); + }); +} + +export function placeholderErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_placeholders', message: 'must be an array of literal placeholders' }); return; } + if (value.length > MAX_PLACEHOLDERS) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_placeholders', message: `more than ${MAX_PLACEHOLDERS} placeholders` }); + value.forEach((p, i) => { + const at = `stamp_placeholders[${i}]`; + if (typeof p !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (!p.trim() || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` }); + else if (p.includes(VERSION_SLOT)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} must not contain ${VERSION_SLOT}` }); + else if (parseVersion(p)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} is a version, not a placeholder` }); + }); +} diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index ac709f890..3070743e8 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -18,6 +18,8 @@ import * as path from 'node:path'; import { defaultBaseBranch, noDefaultBaseLine } from './default-base'; import { RESULT_CODES, type ResultCodeName } from './result-codes'; import { cmpVersion, parseVersion } from './version-source'; +import { placeholderErrors, templateErrors } from './ship-policy-stamps'; +export { VERSION_SLOT } from './ship-policy-stamps'; export const SHIP_POLICY_PATH = '.gstack/ship-policy.json'; export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1'; @@ -280,11 +282,6 @@ const MAX_PATTERN = 256; const MAX_REGEX = 512; const MAX_COMMAND = 512; const SHA_RE = /^[0-9a-f]{7,64}$/; -const MAX_TEMPLATES = 16; -const MAX_TEMPLATE = 128; -const MAX_PLACEHOLDERS = 16; -const MAX_PLACEHOLDER = 32; -export const VERSION_SLOT = '{version}'; function hasDotDot(p: string): boolean { return p.split(/[\\/]+/).some(seg => seg === '..'); @@ -351,29 +348,6 @@ function excludeErrors(value: unknown, out: PolicyError[]): void { }); } -function templateErrors(value: unknown, out: PolicyError[]): void { - if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; } - if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` }); - value.forEach((t, i) => { - const at = `stamp_patterns[${i}]`; - if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } - if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` }); - else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` }); - }); -} - -function placeholderErrors(value: unknown, out: PolicyError[]): void { - if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_placeholders', message: 'must be an array of literal placeholders' }); return; } - if (value.length > MAX_PLACEHOLDERS) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_placeholders', message: `more than ${MAX_PLACEHOLDERS} placeholders` }); - value.forEach((p, i) => { - const at = `stamp_placeholders[${i}]`; - if (typeof p !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } - if (!p.trim() || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` }); - else if (p.includes(VERSION_SLOT)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} must not contain ${VERSION_SLOT}` }); - else if (parseVersion(p)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} is a version, not a placeholder` }); - }); -} - function migrationErrors(value: unknown, out: PolicyError[]): void { const m = value as Record | null; if (!m || typeof m !== 'object' || Array.isArray(m)) { out.push({ code: 'POLICY_INVALID', path: 'migrations', message: 'must be { dir, id } or null' }); return; } From ef8ed538dfa1a44652352e9cc1f51f83f929337c Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 04:56:59 +0000 Subject: [PATCH 12/14] fix(ship-policy): stamp_placeholders key names the next minor 1.95.0.0 1.94.0.0 shipped as #3901 without the key, so the released-key test reds once main's CHANGELOG is in the tree. The slot stamp moves the tentative value through stamp_paths. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 3070743e8..17b6ffbe1 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -242,7 +242,7 @@ export const BUMP_NEVER_KEY_VERSION = '1.91.353.0'; export const BASE_RED_KEY_VERSION = '1.91.374.0'; /** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0'; -export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.0.0'; +export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.95.0.0'; export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', From d00794b505d9747e4aa663e9caf91fe51a230509 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 09:02:41 +0000 Subject: [PATCH 13/14] test(restamp-mentions): set the stamp file's exec bit through the index so the mode-change carry case holds on Windows Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/restamp-mentions.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index 7bc14c5b2..a74641aa0 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -427,7 +427,9 @@ describe('a branch-added row left at the fork version after the base moved (GST- const { repo, body } = carryAfterStamp('restamp-carry-mode-', [row('1.2.3.0', 'base_row')], row('1.2.3.0', 'mine'), [row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]); fs.chmodSync(path.join(repo.work, NOTICE), 0o755); - repo.git('commit', '-qam', 'stamp 1.2.5.0, and make the file executable'); + repo.git('add', '-A'); + repo.git('update-index', '--chmod=+x', '--', NOTICE); + repo.git('commit', '-qm', 'stamp 1.2.5.0, and make the file executable'); expect(repo.git('ls-tree', 'HEAD', '--', NOTICE)).toStartWith('100755 '); const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); expect(carried.status, carried.out).toBe(EXIT.refused); From 13601c0440f391744b70e651c7acc284f86978cf Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 09:13:35 +0000 Subject: [PATCH 14/14] unstamp: return VERSION, package.json, CHANGELOG and the digest to main f6d6215's values and restore the changelog fragment after #3892 left train 100 The tentative STAMP_PLACEHOLDERS_KEY_VERSION goes back to 1.95.0.0 for the next slot stamp to move. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 20 ------------------- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- .../gst-1026-1030-stamp-placeholders.md | 17 ++++++++++++++++ lib/ship-policy.ts | 2 +- package.json | 2 +- 6 files changed, 21 insertions(+), 24 deletions(-) create mode 100644 changelog.d/gst-1026-1030-stamp-placeholders.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d5f9f04..c432700aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,25 +1,5 @@ # Changelog -## [1.94.11.0] - 2026-11-03 - -**A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1). - -### Added -- **`stamp_placeholders` policy key** (default `[]`, so nothing changes for a repo that does not set it): literal placeholders such as `["TBD"]`, at most 16, one line and 32 characters each, never `{version}` and never a version. It needs `min_gstack_version` at the release that introduced it. - -### Fixed -- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. -- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded. -- **`bin/gstack-ship-receipt` carry, pin-bump PRs:** a `package.json` the branch changed as product (a dependency pin) that both main and a restamp moved since review is compared with only its `version` field masked, at all four sides. The receipt carries when nothing else in the file moved, and still refuses when the pin or any other field changed (GBEV-3, gbrain-evals #306). - -### Tests -- `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. -- `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls. -- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product; a pin-bump PR's receipt carries after a main merge and a restamp, and refuses when the pin moved since review. -- `test/gstack-ship-policy.test.ts`: the key's default, floor and validation. - -The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it. - ## [1.94.10.0] - 2026-11-03 **Windows checks now run by path.** Windows Setup E2E, the 10-to-25-minute install test, no longer starts its Windows jobs on a pull request whose only install change is `package.json`'s version line, which the merge-train stamp puts on every pull request before it merges. Of the 61 pull requests in its last 100 runs (as of 2026-11-03), 37 changed no install file besides `package.json`. Its path list also gains `bun.lock` and the `bin/` scripts that the test or `./setup` runs. A new `Windows Scoped Tests` workflow runs the Windows-safe test files a pull request changed, on one Windows job, when the pull request touches path, file-name or process code, such as a hook script or a new `spawnSync` call. On the last 120 commits to main (through 923fc0a) it would have run on 50, with a median of one test file. It catches both Windows breaks the plan names: /freeze's path check (#3758) and a test that started Bun with `--config=/dev/null` (#3829). A red run blocks its pull request like any failed check, so this release also fixes #3829's test, which has failed every nightly Windows run since it landed. `windows-smoke` still runs on every pull request, and the full Windows suite still runs nightly. diff --git a/VERSION b/VERSION index 693802e83..0ca771ae3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.11.0 +1.94.10.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 8606681ff..06472d955 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.11.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.10.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/changelog.d/gst-1026-1030-stamp-placeholders.md b/changelog.d/gst-1026-1030-stamp-placeholders.md new file mode 100644 index 000000000..31ddbd11f --- /dev/null +++ b/changelog.d/gst-1026-1030-stamp-placeholders.md @@ -0,0 +1,17 @@ +**A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1). + +### Added +- **`stamp_placeholders` policy key** (default `[]`, so nothing changes for a repo that does not set it): literal placeholders such as `["TBD"]`, at most 16, one line and 32 characters each, never `{version}` and never a version. It needs `min_gstack_version` at the release that introduced it. + +### Fixed +- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. +- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded. +- **`bin/gstack-ship-receipt` carry, pin-bump PRs:** a `package.json` the branch changed as product (a dependency pin) that both main and a restamp moved since review is compared with only its `version` field masked, at all four sides. The receipt carries when nothing else in the file moved, and still refuses when the pin or any other field changed (GBEV-3, gbrain-evals #306). + +### Tests +- `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. +- `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls. +- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product; a pin-bump PR's receipt carries after a main merge and a restamp, and refuses when the pin moved since review. +- `test/gstack-ship-policy.test.ts`: the key's default, floor and validation. + +The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it. diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index a1f042df7..17b6ffbe1 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -242,7 +242,7 @@ export const BUMP_NEVER_KEY_VERSION = '1.91.353.0'; export const BASE_RED_KEY_VERSION = '1.91.374.0'; /** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0'; -export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.11.0'; +export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.95.0.0'; export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', diff --git a/package.json b/package.json index b7c574bf4..3488c9cb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.11", + "version": "1.94.10", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",