From fc292e22f5708da2e33f3edeaf641374d0144a9e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 08:32:10 -0800 Subject: [PATCH] v1.93.7.0 fix(restamp): a branch-added stamp at the fork version moves, and every stamp check accepts it (GST-963) (#3862) * fix(restamp): a branch-added stamp left at the fork version is stale (GST-963) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(stamp): one side-aware stamp bound for the tree receipt, carry and finish (GST-963) A stamp row the branch added at its fork version now passes every stamp check, not only the restamp writer: stampOnlyLine takes the branch side (its own lines and fork version), and stampedFiles, the tree receipt and the receipt carry all pass it. The stamp-only checks move to lib/stamp-only.ts to keep lib/restamp.ts under the module-size ratchet. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train): the train stamp and verify checks read the slot predecessor as the base (GST-963) The train restamp moves a branch-added row left at the predecessor version, so stampMismatch and the three train-verify receipts pass baseRef (the gated commit first parent), and the tree receipt applies the branch side even when the slot keeps its claimed VERSION. Failing-first: test/ship-queue-train-stamp.test.ts refused with QUEUE_TRAIN_STAMP_MISMATCH before this commit; on main the pin stamps but the row stays stale. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GST-963 fragment names the carry and duplicate-text gaps (GST-979, GST-980) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): stamp_paths names the carry and duplicate-text gaps (GST-979, GST-980) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-ship-receipt | 7 +- bin/gstack-tree-receipt | 4 +- docs/ship-policy.md | 2 +- lib/restamp.ts | 38 ++--------- lib/ship-bin-common.ts | 28 ++------ lib/ship-queue-train-stamp.ts | 2 +- lib/ship-queue-train-verify.ts | 6 +- lib/ship-queue.ts | 2 +- lib/ship-receipt.ts | 9 +-- lib/stamp-only.ts | 70 +++++++++++++++++++ lib/tree-receipt.ts | 26 ++++--- package.json | 2 +- test/gstack-ship-receipt-docs.test.ts | 13 ++++ test/restamp-base-merge.test.ts | 3 +- test/restamp-mentions.test.ts | 82 ++++++++++++++++++++++- test/ship-queue-train-stamp.test.ts | 15 +++++ test/ship-rereview-triggers.test.ts | 5 +- test/tree-receipt-stamp-sensitive.test.ts | 2 +- 21 files changed, 247 insertions(+), 88 deletions(-) create mode 100644 lib/stamp-only.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c60276090..65c56ce2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.93.7.0] - 2026-11-02 + +**A version stamp the branch added at its fork point's version now counts as stale after the base moves (GST-963).** On gbrain #8425, a branch added a row that named the fork point's version. After main moved past it, `gstack-restamp` skipped the row ("no tentative mention") and `gstack-ship-release finish` routed the line as a behavior change, so the stamp kept the old version and the docs stage flagged a product edit. + +### Fixed +- **`lib/restamp.ts`:** a stamp_patterns match on a branch-added line that names the fork point's version is rewritten to the new version, like a mention of a newer tentative version. Lines the branch did not add, and versions below the fork point, are left alone. +- **`lib/ship-bin-common.ts`:** finish routes such a line as metadata (a stamp file), not behavior. +- **One stamp bound for the writer and the stamp checks** (`lib/stamp-only.ts` `stampOnlyLine` with the branch's side): the tree receipt behind `gstack-ship-queue stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (read against the slot's predecessor), and finish's route now accept a moved version on a line the branch added at or above the fork-point version. Before, the tree receipt and the carry still floored every line at the gated head's VERSION, so the stamp commit the writer now makes would have read `TREE: changed` and stopped the train with `QUEUE_STAMP_NOT_STAMP_ONLY`. The same move on a base line is still refused, unless that base line's text duplicates a line the branch added (GST-979). The tree receipt and the train checks also accept the move when the branch's own VERSION already equals the new version, as it does in a train slot that kept its claim. Two gaps remain, tracked in GST-980: 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. + +### Tests +- `test/restamp-mentions.test.ts`: a branch-added row at the fork version after the base moved is restamped and its file is routed as metadata. On main, the stage reports `skipped` and ROUTE counts the file as behavior. +- `test/ship-rereview-triggers.test.ts`: the GST-895 fork-version case now routes as metadata. +- `test/restamp-mentions.test.ts`: `gstack-tree-receipt` reads the GST-963 stamp commit as `same-modulo-stamps` (it read `TREE: changed` before the shared bound), refuses the same move on a base row, and still accepts the GST-895 lost-slot stamp. `test/gstack-ship-receipt-docs.test.ts`: the carry takes the fork-version bound only on the branch's own line. +- `test/ship-queue-train-stamp.test.ts`: a pin claiming 1.2.4.0 with a row left at the base's 1.2.3.0 stamps, and the row moves to 1.2.4.0. Without the train checks' bound, `stampChain` refused it with `QUEUE_TRAIN_STAMP_MISMATCH`. On main it stamped, but the row stayed at 1.2.3.0. + ## [1.93.6.0] - 2026-11-02 **/plan-tune now learns from answers to questions that aren't in its registry, when the skill says which preference the question is about (#3442).** Before, an answer to an ad-hoc question id such as `plan-ceo-review-add-caching` moved no dimension, because only registered ids carry a signal. Most logged answers are on ad-hoc ids, so inferred values stayed near 0.5. diff --git a/VERSION b/VERSION index 46b181ff6..d0c2a1748 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.93.6.0 +1.93.7.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 8411cc708..fd0e444d0 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.93.6.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.93.7.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/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 3de38d032..773fb19c1 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -37,6 +37,7 @@ import * as path from 'node:path'; import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts'; import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { versionAtRef } from '../lib/restamp'; +import { branchSides, type BranchSide } from '../lib/stamp-only'; import { runScriptSync } from '../lib/script-invocation'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; @@ -495,7 +496,7 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[] * change, such as a CHANGELOG fragment next to the base's changelog.d/README.md * (GST-343, #3606), into the product patch-id at one head only. */ -function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet; sides: ReadonlyArray }): { 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[] } | undefined { const changedRaw = git(['diff', '--name-only', from, to], repoRoot); if (changedRaw === undefined) return undefined; const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; @@ -507,7 +508,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const versions = new Map(); const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; }; const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p); - const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates } : undefined); + const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined); const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) })); const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path) @@ -620,7 +621,7 @@ function cmdCarry(rest: string[], json: boolean): number { const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)]; if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`); const touched = new Set([...listed[0], ...listed[1]]); - const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]] }); + const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') }); if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); const { changed } = spec; const productFrom = patchId(repoRoot, fromBase, from, spec.product); diff --git a/bin/gstack-tree-receipt b/bin/gstack-tree-receipt index e718d3738..9212a2e2c 100755 --- a/bin/gstack-tree-receipt +++ b/bin/gstack-tree-receipt @@ -64,10 +64,10 @@ function main(argv: string[]): number { if (predSha || predPr || predRef) predecessor = { pr: predPr ?? predecessor?.pr ?? null, recordedHead: predSha ?? predecessor?.recordedHead ?? null, ref: predRef ?? null }; const policyFrom = flag(argv, '--policy-from') ?? 'base'; + const base = resolveBaseBranch(repoRoot, flag(argv, '--base')); let loaded: LoadedPolicy; if (policyFrom === 'head') loaded = loadPolicyFromHead(repoRoot); else { - const base = resolveBaseBranch(repoRoot, flag(argv, '--base')); const l = loadPolicyFromBase(repoRoot, base); if (isUnavailable(l)) { process.stderr.write(policyUnavailableLine('gstack-tree-receipt', base, l.detail) + '\n'); return EXIT.refused; } loaded = l; @@ -76,7 +76,7 @@ function main(argv: string[]): number { const receipt = buildReceipt({ repoRoot, gated, current: flag(argv, '--current'), policy: loaded.policy, policyLabel: loaded.source.kind === 'head' ? 'head (unreviewed)' : loaded.source.label, - predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), + predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), baseRef: `origin/${base}`, }); if ('error' in receipt) { process.stderr.write(resultLine('gstack-tree-receipt', receipt.code, receipt.error) + '\n'); return EXIT.fail; } process.stdout.write(json ? JSON.stringify(receipt) + '\n' : renderReceipt(receipt)); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 4c0cd2027..4adac84b1 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 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. +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). 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 06b4a8828..45dd82ff7 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -362,36 +362,9 @@ export function stampMentions(text: string, templates: readonly string[] = []): return found; } -/** - * A removed/added line pair that a restamp alone explains: every version the - * removed line mentions in a stamp form and the added line no longer does - * moves to `newV`, and the rewritten line is the added line byte for byte. - * The moved versions are the ones the stamp_paths stage moves, not only the - * old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = ''` never equals the base version at the slot and is a stamp all the - * same (#3512). The stage's own bound applies: a tentative mention is newer - * than the version the branch was written against, so a moved version older - * than `oldV` (a released key moved by hand) is not a stamp, and neither is - * one of another width (`tentativeMentions`: a 3-digit dependency pin in a - * 4-digit repo is never a candidate, so moving it is a hand edit). A line that - * moves nothing, or changes anything beyond those mentions, is not one either. - */ -export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean { - if (removed === added) return false; - const floor = parseVersion(oldV); - const width = versionWidth(oldV); - const after = stampMentions(added, templates); - const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v)); - if (!moved.length || !floor) return false; - if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false; - let text = removed; - for (const v of moved) text = rewriteStamps(text, v, newV, templates).text; - return text === added; -} - /** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */ -export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string { - const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim(); +export function forkVersion(repoRoot: string, baseRef: string, fallback: string, head = 'HEAD'): string { + const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..${head}`]).out.split('\n')[0]?.trim(); if (!first) return fallback; const v = versionAtRef(repoRoot, `${first}^`); return 'version' in v ? v.version : fallback; @@ -525,7 +498,9 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string /** * The stamp_paths stage. An explicit old version (the branch's own stamp, or * `--was`) is rewritten on every line of every stamp file; a tentative one - * (a mention newer than the fork version) only on lines the branch added. + * (a mention newer than the fork version, or of the fork version itself: a + * row written at the version the branch forked from instead of a next one, + * GST-963) only on lines the branch added. * With no explicit version the branch's VERSION equals the base's: either it * never stamped, or it stamped X and the base reached X on its own (a PR that * lost its slot and merged the base). Then a stamp-form mention of the base @@ -548,6 +523,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v))); const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null; if (equalBase) tentative.add(equalBase); + if (plan.forkVersion !== plan.newVersion && !explicit.has(plan.forkVersion)) tentative.add(plan.forkVersion); const moved = new Map(); const baseSide: string[] = []; let touched = 0; @@ -590,7 +566,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const baseOwnNote = baseOwn.length ? `; ${baseOwn.reduce((n, [, own]) => n + own.notOwn, 0)} line(s) the base added since the fork point are the base's, not this branch's, and stay (${baseOwn.map(([rel, own]) => `${rel}: ${own.notOwn}`).join('; ')})` : ''; sp.detail = (olds.length ? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}` - : `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote; + : `${searched}no tentative mention at or newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote; } function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] { diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 630a33ea1..6bdd582dc 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -10,7 +10,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; -import { branchAddedLines, forkVersion, parseUnifiedDiff, stampMentions, stampOnlyLine, versionAtRef } from './restamp'; +import { parseUnifiedDiff, versionAtRef } from './restamp'; +import { branchSides, stampOnlyLine } from './stamp-only'; import { matchesAny } from './ship-policy'; import { extractVersion } from './version-source'; import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation'; @@ -109,8 +110,8 @@ export function releasePaths(policy: Record | null, cwd: string, re * old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile). * 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 newer than the fork point's version instead, the - * bound gstack-restamp's stamp_paths stage uses (GST-895). + * 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). */ 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') : []; @@ -123,25 +124,8 @@ export function stampedFiles(policy: Record | null, cwd: string, re const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : []; const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]); if (diff.status !== 0) return []; - let fork: string | undefined; - let since: string | undefined; - const own = new Map>(); - const ownLines = (rel: string): Set => { - if (own.has(rel)) return own.get(rel)!; - since ??= git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim(); - let lines = new Set(); - if (since) { - try { lines = branchAddedLines(cwd, since, [rel], ref, branchAddedLines(cwd, since, [rel], `origin/${base}`)).get(rel)?.text ?? lines; } catch {} - } - own.set(rel, lines); - return lines; - }; - const stamp = (rel: string, removed: string, added: string): boolean => { - if (stampOnlyLine(removed, added, before.version, after, templates)) return true; - if (!ownLines(rel).has(removed)) return false; - fork ??= forkVersion(cwd, `origin/${base}`, before.version); - return stampOnlyLine(removed, added, fork, after, templates) && (!stampMentions(removed, templates).has(fork) || stampMentions(added, templates).has(fork)); - }; + const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref); + const stamp = (rel: string, removed: string, added: string): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel)); return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 && f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path); } diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 19549fe83..340a98568 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -42,7 +42,7 @@ export function isReleasePath(root: string, policy: ShipPolicy, versionRel: stri * byte for byte, and equals T_i outside release paths. Null when all hold, else why not. */ export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null { - const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel }); + const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel, baseRef: `${merged}^1` }); if ('error' in r) return r.error; if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`; const cl = o.policy.changelog; diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 5c7981e00..53119465f 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -34,7 +34,7 @@ import { genericBackstop } from './ship-queue-train-gate'; const Q = 'gstack-ship-queue'; function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same: boolean; detail: string } { - const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${gated}^1` }); if ('error' in r) return { same: false, detail: r.error }; return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` }; } @@ -79,7 +79,7 @@ function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: const after = t.prefixes[i + 1]!; const root = env.ctx.repoRoot; if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` }; - const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` }); if ('error' in r) return { same: false, detail: r.error }; const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`; if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` }; @@ -105,7 +105,7 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa const post = sameModuloStamps(env, after.commit, main); if (post.same) return post; const cl = env.policy.policy.changelog; - const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` }); if ('error' in r || !cl || r.residual.some(f => f !== cl)) return post; const parent = git(env.ctx.repoRoot, ['rev-parse', '--verify', '-q', `${main}^`]).out; if (!parent || !sameModuloStamps(env, t.prefixes[i]!.commit, parent).same) return { same: false, detail: `${post.detail}; main's parent ${parent.slice(0, 12) || '(none)'} is not T${t.prefixes[i]!.index} modulo stamps` }; diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index dea5df303..7d5dec193 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -66,7 +66,7 @@ export function receiptDigest(r: TreeReceipt): string { } function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, current: string): TreeReceipt | QueueFailure { - const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label }); + const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label, baseRef: `${ctx.remote ?? 'origin'}/${ctx.base}` }); return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r; } diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index b628a0aba..1b1c5ed83 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -12,7 +12,7 @@ import { FRAGMENT_README } from './changelog-fragments'; import { renderIssueLines } from './issue-links'; import { matchesAny } from './ship-policy'; import { stampFileKind, versionStampOnly } from './pregate/stamp-only'; -import { stampOnlyLine } from './restamp'; +import { stampOnlyLine, type BranchSide } from './stamp-only'; export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array = [ @@ -188,13 +188,14 @@ const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test an * The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else * `package.json`) and the base policy's `stamp_patterns` templates. */ -export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] } +export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide } /** * A policy-named stamp path's change is a stamp when the new text reads as the * old text once every new version in it is the old one, or when every changed * line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old - * version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule + * version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512, and + * on a line the branch added itself, `side`, the fork version or newer, GST-963): the rule * the tree receipt applies to the same `stamp_paths`, `stamp_files` and * `release_outputs` before the train accepts a stamp commit. No version * change, or a file missing on either side, is not a stamp. @@ -209,7 +210,7 @@ export function stampOnlyText(change: CarryChange): boolean { const now = after.split('\n'); if (was.length !== now.length) return false; const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]); - return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [])); + return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side)); } /** diff --git a/lib/stamp-only.ts b/lib/stamp-only.ts new file mode 100644 index 000000000..3b0d2d1e7 --- /dev/null +++ b/lib/stamp-only.ts @@ -0,0 +1,70 @@ +/** + * Stamp-only checks: whether a removed/added line pair is a restamp alone, and + * the branch side (its own lines and fork version) that bounds that check for + * lines the branch wrote itself. Split from restamp.ts (module-size ratchet). + */ +import { spawnSync } from 'node:child_process'; +import { branchAddedLines, forkVersion, rewriteStamps, stampMentions } from './restamp'; +import { cmpVersion, parseVersion, versionWidth } from './version-source'; + +/** + * A removed/added line pair that a restamp alone explains: every version the + * removed line mentions in a stamp form and the added line no longer does + * moves to `newV`, and the rewritten line is the added line byte for byte. + * The moved versions are the ones the stamp_paths stage moves, not only the + * old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = ''` never equals the base version at the slot and is a stamp all the + * same (#3512). The stage's own bound applies: a tentative mention is newer + * than the version the branch was written against, so a moved version older + * than `oldV` (a released key moved by hand) is not a stamp, and neither is + * one of another width (`tentativeMentions`: a 3-digit dependency pin in a + * 4-digit repo is never a candidate, so moving it is a hand edit). A line that + * moves nothing, or changes anything beyond those mentions, is not one either. + * With `side`, a removed line the branch added itself is bounded by its fork + * point's version instead, the stage's bound for its own lines (GST-895, + * GST-963): finish's stampedFiles, the tree receipt and the receipt carry all + * pass the same side. + */ +export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide): boolean { + if (removed === added) return false; + return stampOnlyWithin(removed, added, oldV, newV, templates) || (!!side?.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates)); +} + +/** Lines the branch added to one file (since its merge base with the base, less the lines the base added since) and its fork point's version. */ +export interface BranchSide { fork: string; added: ReadonlySet } + +/** + * Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to` + * (`head` unless a reviewed tree stands in for it), the fork version from `head`'s first-parent history + * (`fallback` when it has no own commit). A file git cannot diff has no side, so its lines keep the `oldV` bound. + */ +export function branchSides(repoRoot: string, baseRef: string, head: string, fallback: string, to = head): (rel: string) => BranchSide | undefined { + const sides = new Map(); + let since: string | undefined; + let fork: string | undefined; + return rel => { + if (sides.has(rel)) return sides.get(rel); + since ??= (spawnSync('git', ['merge-base', baseRef, head], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }).stdout ?? '').trim(); + let side: BranchSide | undefined; + if (since) { + try { + const added = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel)?.text; + if (added?.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added }; + } catch {} + } + sides.set(rel, side); + return side; + }; +} + +function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean { + const floor = parseVersion(oldV); + const width = versionWidth(oldV); + const after = stampMentions(added, templates); + const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v)); + if (!moved.length || !floor) return false; + if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false; + let text = removed; + for (const v of moved) text = rewriteStamps(text, v, newV, templates).text; + return text === added; +} diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index c946ffa71..7ba350205 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -16,7 +16,8 @@ import * as path from 'node:path'; import { normalizeChangelog } from './changelog-check'; import { foldIntoUnreleased, parseFragment, type Fragment } from './changelog-fragments'; import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle'; -import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp'; +import { parseUnifiedDiff, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp'; +import { branchSides, stampOnlyLine, type BranchSide } from './stamp-only'; import type { ResultCodeName } from './result-codes'; import { matchesAny, type ShipPolicy } from './ship-policy'; @@ -55,7 +56,7 @@ function blob(cwd: string, treeish: string, rel: string): string | null { * between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new * heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree. */ -export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] { +export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, side?: (rel: string) => BranchSide | undefined): FileVerdict[] { const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null; const folded = new Map(); for (const f of fileDiffs) { @@ -65,11 +66,11 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st if (parsed && !('code' in parsed)) folded.set(f.path, parsed); } const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); - const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments); + const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments, side); const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog); const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp'; return fileDiffs.map((f): FileVerdict => { - if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV); + if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, [], side); return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` }; }); } @@ -77,15 +78,15 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st /** * Every removed/added pair differs only by stamp rewrites to the new version: * the old version, or a tentative one (newer than it) the stamp_paths stage - * moved (#3512), each in a stamp form (`stampOnlyLine`). + * moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines). */ -function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null { +function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null { for (const h of hunks) { if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at }; for (let i = 0; i < h.removed.length; i++) { const r = h.removed[i]!; const a = h.added[i]!; - if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; + if (!stampOnlyLine(r, a, oldV, newV, templates, side)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; } } return null; @@ -96,7 +97,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string return normalizeLockfile(name, a) === normalizeLockfile(name, b); } -export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = []): FileVerdict { +export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = [], side?: (rel: string) => BranchSide | undefined): FileVerdict { const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line }); const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' }); if (f.binary) return changed('binary', 'binary content differs'); @@ -127,8 +128,9 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre return changed('changelog', folded.length ? 'entry text is not exactly the deleted fragments folded under the new heading' : 'entry text changed (only a heading move or re-heading is a stamp)'); } const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null; - if (oldV && newV && oldV !== newV && rule) { - const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns); + const own = rule ? side?.(f.path) : undefined; + if (oldV && newV && (oldV !== newV || own) && rule) { + const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own); return bad ? changed(rule, bad.why, bad.line) : stamp(rule); } return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`); @@ -136,6 +138,8 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre export interface ReceiptOptions { repoRoot: string; gated: string; current?: string; policy: ShipPolicy; policyLabel: string; versionRel?: string; + /** The base ref the gated head's own lines are read against (the fork-version bound, GST-963); without it every line keeps the old version's bound. */ + baseRef?: string; predecessor?: { pr: string | null; recordedHead: string | null; ref?: string | null; currentHead?: string | null }; bundlePath?: string; gh?: (args: string[]) => { ok: boolean; out: string }; env?: NodeJS.ProcessEnv; rerunCommand?: string; } @@ -176,7 +180,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string; return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' }; } const diffText = d.stdout.replace(/\n$/, ''); - const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV); + const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV, o.baseRef && oldV ? branchSides(o.repoRoot, o.baseRef, gatedHead, oldV) : undefined); const residual = files.filter(f => f.verdict === 'changed').map(f => f.path); const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path); const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : ''; diff --git a/package.json b/package.json index 80c2ea4d7..44200cf82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.93.6", + "version": "1.93.7", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-ship-receipt-docs.test.ts b/test/gstack-ship-receipt-docs.test.ts index e889e56ae..497d49c07 100644 --- a/test/gstack-ship-receipt-docs.test.ts +++ b/test/gstack-ship-receipt-docs.test.ts @@ -218,6 +218,19 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit expect(stampOnlyText({ before: 'left-pad since: 1.2.3\n', after: 'left-pad since: 1.2.4.0\n', oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false); }); + // GST-963: the carry shares the stage's bound, so a key version the branch added at its fork version (older than the + // pin's VERSION after a base merge) is a stamp on the branch's own line and a hand edit on any other. + test('stampOnlyText takes the fork-version bound only on a line the branch added', () => { + const templates = ["_KEY_VERSION = '{version}'"]; + const mine = "export const MINE_KEY_VERSION = '1.2.3.0';"; + const before = `${mine}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n`; + const moved = (text: string) => ({ before, after: text, oldVersion: '1.2.4.0', newVersion: '1.2.5.0', templates }); + const side = { fork: '1.2.3.0', added: new Set([mine]) }; + expect(stampOnlyText({ ...moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))), side })).toBe(true); + expect(stampOnlyText(moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))))).toBe(false); + expect(stampOnlyText({ ...moved(before.replace("BASE_KEY_VERSION = '1.2.3.0'", "BASE_KEY_VERSION = '1.2.5.0'")), side })).toBe(false); + }); + test('planCarry names at most CARRY_NAMED_PATHS paths', () => { const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`); const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' }); diff --git a/test/restamp-base-merge.test.ts b/test/restamp-base-merge.test.ts index d8d6944d7..471dc006e 100644 --- a/test/restamp-base-merge.test.ts +++ b/test/restamp-base-merge.test.ts @@ -17,7 +17,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { mergeChangelogs } from '../lib/changelog-check'; -import { branchAddedLines, stampOnlyLine } from '../lib/restamp'; +import { branchAddedLines } from '../lib/restamp'; +import { stampOnlyLine } from '../lib/stamp-only'; import { gitArgvIn } from './helpers/scratch-repo'; import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index a59c0e9c4..3913d7e92 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -15,7 +15,7 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { rewriteStamps, tentativeMentions } from '../lib/restamp'; import type { GitFixtureTemplate } from './helpers/git-fixture-template'; -import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; +import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); @@ -88,6 +88,11 @@ const notice = (rows: string[]) => ['export const ROWS = [', ...rows, '];', '']. function policy(templates: boolean) { return { ...BASE_POLICY, release_tool: null, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', NOTICE], ...(templates ? { stamp_patterns: ["since: '{version}'"] } : {}) }; } +/** The policy plus the lockfile --next stamps, so the tree receipt's stamp_files allowlist covers the whole stamp commit. */ +function receiptPolicy(templates: boolean) { + const p = policy(templates); + return { ...p, stamp_files: [...p.stamp_files, 'package-lock.json'] }; +} function write(repo: FixtureRepo, rel: string, text: string): void { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), text); @@ -157,7 +162,7 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(r.status).toBe(EXIT.ok); const sp = stageDetail(r.out, 'stamp_paths')!; expect(sp[1]).toBe('skipped'); - expect(sp[2]).toBe('no tentative mention newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)'); + expect(sp[2]).toBe('no tentative mention at or newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)'); expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"])); expect(r.out).not.toMatch(/^[-+] \{ since: '1\.2\.3\.0'/m); }); @@ -243,6 +248,18 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete'); }); + // Value: protects=the lost-slot stamp the stage writes is one the train's tree receipt accepts (GST-963 review: one bound + // for the writer and the checks); fails_when=the tree receipt reads the moved own row as a product change; seam=stampOnlyLine + test('the lost-slot stamp passes the tree receipt', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-mentions-receipt-', policy: receiptPolicy(true) }); repos.push(repo); + lostSlot(repo); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp mine'); + const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(receipt.out, receipt.err).toContain('TREE: same-modulo-stamps'); + }); + test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => { const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo); lostSlot(repo); @@ -252,3 +269,64 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.5.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')])); }); }); + +describe('a branch-added row left at the fork version after the base moved (GST-963, GBRA-277, gbrain #8425)', () => { + // Value: protects=a stamp-form row the branch added at its fork version (not a tentative next version) is its own + // stale stamp once the base has moved past the fork, so --next moves it; the base's rows at that version, and a + // branch-added row older than the fork, stay; fails_when=the stage skips with "no tentative mention newer than the + // fork version" and leaves the row at the fork version (gbrain #8425), or moves a base row or a pre-fork row; + // seam=lib/restamp.ts rewriteStampPaths; no_claim=finish's route (test/ship-rereview-triggers.test.ts) + test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the fork-version row moves, the base rows and an older branch row stay', (_, templates) => { + const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-', policy: receiptPolicy(templates) }); repos.push(repo); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')])); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-mine'); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.2.0', 'older'), '', row('1.2.3.0', 'mine')])); + repo.git('commit', '-qam', 'mine: a row at the fork version'); + makePr(repo, 'pr-other'); + write(repo, NOTICE, notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')])); + repo.git('commit', '-qam', 'other row'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-mine'); + mergeMain(repo); + expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n'); + const gated = repo.git('rev-parse', 'HEAD'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(stageDetail(r.out, 'stamp_paths')![1]).toBe('done'); + expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', row('1.2.2.0', 'older'), '', row('1.2.5.0', 'mine')])); + repo.git('commit', '-qam', 'stamp mine'); + const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(receipt.out, receipt.err).toContain('TREE: same-modulo-stamps'); + }); + + // Value: protects=the four stamp checks share one bound, so a stamp the writer makes is one the train accepts; + // fails_when=the tree receipt (stamp, verify, gate-ahead replay) reads a moved fork-version row, or a base row at that + // version moved by hand, the same way; seam=lib/stamp-only.ts stampOnlyLine with the branch side + test('the tree receipt accepts the moved branch row and refuses the same move on a base row', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-receipt-', policy: receiptPolicy(true) }); repos.push(repo); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')])); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-mine'); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.3.0', 'mine')])); + repo.git('commit', '-qam', 'mine: a row at the fork version'); + makePr(repo, 'pr-other'); + write(repo, 'other.txt', 'other\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-mine'); + mergeMain(repo); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + write(repo, NOTICE, readFile(repo, NOTICE).replace(row('1.2.3.0', 'base_row'), row('1.2.5.0', 'base_row'))); + repo.git('commit', '-qam', 'stamp mine, and move the base row by hand'); + const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(receipt.out).toContain('TREE: changed'); + expect(receipt.out).toContain("id: 'base_row'"); + expect(receipt.out).not.toContain("id: 'mine'"); + }); +}); diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts index 8ae814052..4dd9f7f27 100644 --- a/test/ship-queue-train-stamp.test.ts +++ b/test/ship-queue-train-stamp.test.ts @@ -136,4 +136,19 @@ describe('stamped train prefixes', () => { const product = tamper('a.txt', () => 'not a\n'); expect(stampMismatch(o, merged, product, o.plain[2]!.tree)).toContain('a.txt'); }, 60_000); + + // Value: protects=the train's stamp check bounds a branch-added row the same way the train restamp writes it, so a pin + // with a row left at the fork version still stamps; fails_when=stampMismatch reads that row against the pin's own + // VERSION and refuses the moved row; seam=lib/ship-queue-train-stamp.ts stampMismatch baseRef (GST-963) + test('a pin claiming 1.2.4.0 with a stamp_paths row left at the base 1.2.3.0 stamps, and the row moves (GST-963)', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + pin(r, 'f', 'origin/main', '- f change\n'); + fs.writeFileSync(path.join(r.work, 'VERSION'), '1.2.4.0\n'); + fs.writeFileSync(path.join(r.work, 'docs', 'f.md'), '// since: 1.2.3.0\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'f: claims 1.2.4.0, row at the fork version'); + const out = stampChain(chain(r, [['1', r.git('rev-parse', 'HEAD')]])); + if ('code' in out) throw new Error(`${out.code}: ${out.message}`); + expect(out.stamped.map(s => s.version)).toEqual(['1.2.4.0']); + expect(show(r, out.stamped[0]!.commit + ':docs/f.md')).toBe('// since: 1.2.4.0'); + }, 60_000); }); diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 581c95713..4e465c0a4 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -206,7 +206,8 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => // Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older // than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the // base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row - // at or older than the fork point, moved the same way still stops; + // older than the fork point, moved the same way still stops; a branch row at the fork version is the branch's own + // stale stamp and routes as metadata (GST-963, gbrain #8425); // fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or // drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] }); @@ -239,7 +240,7 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => const atFork = lostSlot('1.0.0.0'); atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version'); const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]); - expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]); + expect([lineOf(a.stdout, 'ROUTE'), lineOf(a.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]); const edited = lostSlot('1.0.1.0'); edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin'); diff --git a/test/tree-receipt-stamp-sensitive.test.ts b/test/tree-receipt-stamp-sensitive.test.ts index 245c11962..569cc83cd 100644 --- a/test/tree-receipt-stamp-sensitive.test.ts +++ b/test/tree-receipt-stamp-sensitive.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { stampOnlyLine } from '../lib/restamp'; +import { stampOnlyLine } from '../lib/stamp-only'; import { BASE_POLICY, cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = [];