From b42ad1e300fad1641ef1bc65c876701d235d921d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 19:34:35 -0800 Subject: [PATCH 01/26] v1.93.42.0 fix(stamp): name branch lines by position; carry and finish accept a kept VERSION claim (GST-979, GST-980) (#3889) * 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> * 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> * docs(changelog): GST-979/980 entry counts the seven tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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: 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> * Revert "docs: keep docs/ship-policy.md at the reviewed text; its GST-980 sentence already states the carry rule" This reverts commit f926ccb0c206ce9b1cf2387f3500558a20102510. --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-ship-receipt | 32 +++++-- docs/ship-policy.md | 3 +- docs/troubleshooting.md | 6 +- 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 +- package.json | 2 +- test/gstack-ship-receipt-docs.test.ts | 19 +++- test/restamp-mentions.test.ts | 102 ++++++++++++++++++++++ test/ship-rereview-triggers.test.ts | 18 ++++ test/tree-receipt-stamp-sensitive.test.ts | 16 ++++ 16 files changed, 250 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb22d4629..b27b94ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.93.42.0] - 2026-11-03 + +**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. 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, 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, 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. + ## [1.93.41.0] - 2026-11-03 **Codex outside reviews report their token usage (#2948).** Codex already emits token counts in its `--json` events, and gstack used to throw them away. When `lib/outside-review-result.ts` is given an events file, it now prints `OUTSIDE_USAGE: input=… cached_input=… output=… reasoning_output=…` (only the counters Codex reported), or `OUTSIDE_USAGE: unknown`, on every verdict. diff --git a/VERSION b/VERSION index abacf7c40..e39764546 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.93.41.0 +1.93.42.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3bab33017..693260e81 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.93.41.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.93.42.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 9e417faa9..5fda9dcb6 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'; @@ -516,7 +516,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[]; 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; }; @@ -534,7 +534,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)] }; + // 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. */ @@ -665,6 +677,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 @@ -676,22 +689,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.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); 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)`)); @@ -721,7 +739,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/docs/ship-policy.md b/docs/ship-policy.md index d76edb603..a6fcabd18 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1503,6 +1503,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 @@ -1673,7 +1674,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 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/docs/troubleshooting.md b/docs/troubleshooting.md index 5a9bf8fb5..7ae72bf86 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5989,8 +5989,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)). 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 bb908c088..ff6af045e 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/package.json b/package.json index f27bcb93f..2f2aab339 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.93.41", + "version": "1.93.42", "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 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..68a546a9c 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -329,4 +329,106 @@ 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'); + }); + + /** 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'); + }); }); 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 4ec1722ecbc02a22552e274d17b551c9f722c9fd Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 20:02:41 -0800 Subject: [PATCH 02/26] v1.94.0.0 ship: CI is the one full test run (CI carry, tree cache, /ship beside CI) (#3901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: trim two phrases for headroom with #3662 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: regenerate ship goldens Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251) gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: no test edit skips review; guard packet writes; exclusive attempt state A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: attempt prints gstack's install command; carry checks the product diff outside context paths The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(unattended): carry's scoped-review refusal and what it keeps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals - A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account for the claimed issue and critical counts (GST-430). - The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442). - patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448). - Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin the Step 16 --docs-requested route with its --docs-result Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept - deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot dispose of a gate finding the unattended logger forces open (outside-voice#1). - reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses; noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL. - patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT - isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition. - reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a stale local base branch yields to its origin twin in gate-diff and diff-scope (GST-462) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 11 saves the targeted record and the receipt writes carry it; Step 9 clears it (GST-464) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-464 and GST-462 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): pass 1: ahead and diverged labels, diff-scope needs shared history; preflight refreshes origin/ and stops on a diff far beyond the PR (GST-462, HARN-15) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes for base twin, prSized stop and targeted record Resolve the tracking twin by refs/remotes// and only for a bare branch name, keep a local base that is ahead or diverged, quote the prSized stop's base and name the fork case, and have every Step 9 entry create, exclude and clear .gstack/tmp/targeted.json. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: origin's tracking base wins whenever related; prSized lower bound The fix-check found that keeping a local base ahead of origin left unpushed base commits out of the gate scan while review-plan listed them. The tracking branch now wins whenever it shares history with HEAD, an explicit origin/main reads refs/remotes/origin/main, the preflight also stops when the diff lists fewer files than the PR, and the Step 9 exclude line survives a missing .git/info and an exclude file without a final newline. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: the tracking-ref claim holds while the tracking branch exists Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: repin the two ship section briefs under no-loss replay GST-464 GST-464 brief-identity check: every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md stays byte-identical to the bodies the L and GST-257 replays admitted; only lead instructions outside the cut spans changed. The check is a test CI runs. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: keep Step 9's PASS_HEAD sentence whole; repin review-army under GST-464 The targeted-record line follows the PASS_HEAD/RANGE_BASE sentence instead of splitting it (test/review-fix-range.test.ts pins that sentence). The brief-body check still holds: every reviewer brief is byte-identical. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: restore bin/gstack-ship-receipt after the main merge The merge commit b10e2db wrote a truncated copy of the file. This is the three-way merge of both sides with the refused() union: main's codes plus RECEIPT_CARRY_SCOPED_REVIEW. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: narrow the consumer scan and give it one 300 s deadline (GST-544) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: keep the consumer scan deadline under the 180 s plan timeout (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: plain paths under --literal-pathspecs; whole-millisecond timeout override (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: whole-tree grep for non-UTF-8 names, no argument spread, name spawn errors (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: cap the consumer grep output at 256 MB across calls; per-scan deadline wording (review fix, cycle 4) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: read a missing spawn status as null (Bun 1.4.2 returns undefined on ENOENT) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-collect whole-tree helper reads a null status as -1 (typecheck:test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-bots: /review and /ship triage every configured review bot, not only Greptile (#3122) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (gh before 2.48 pages without --slurp; driver routes Step 10; tests for guards, review-bot fence, REMOTE_SLUG owner) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: drop the review-bot fence test that could not fail (the tool-fence WARN degrade is unreachable: placeholder spans are skipped first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes (record by comment id so a filename never reaches a shell; lookup failures are errors; tojson pages; longest bot prefix wins) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes (record refuses * paths, colliding ids and a missing fetch dir; parent requires id and dir; botOwning doc) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: classify the PR type and always check repo conventions (#356) The checklist now opens with a PR-type line (application, CI/infra, scripts/tooling, docs-only or mixed). Application and mixed diffs run every category; other types always run five categories, including the new Repo Conventions & Platform Fit, and report the rest as not applicable in one line instead of skipping silently. No-loss replay: GSTA-224 H1 preregistered no-loss run (run id gsta224-h1-20261031, Opus 5.5, 81 calls, $9.53) against main's checklist with a no-gstack arm; every preregistered bar passed. The review/checklist.md pin in test/review-packets-removed.test.ts moves to a32963ef with replay 'GSTA-224 H1'. Refs GSTA-224 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-bots: allowlist the record placeholders and match Step 10's id rule in tests The record fence uses , and , each with an enum or numeric grammar; becomes . review/greptile-triage.md also lists every REVIEW_BOTS skip reason. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: review-bot triage covers every configured bot (#3122) README, CONTRIBUTING, the contributor reference and standalone-binaries name gstack-review-bots and the review-bot fence; TODOS drops the #3122 item; the changelog fragment covers record-by-id and lookup errors. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the PR-type test also pins the compose, Terraform and README/CHANGELOG phrases Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow the review-bot rename in the remote free tier's three reds The cross-skill consistency judge describes review-bot-history.md and its record helper; the tracker-guard exemption for the removed raw Greptile fetch in review/greptile-triage.md is dropped (the fetch now lives in gstack-review-bots, whose bodies still reach context only through gstack-issue-guard); the Step 10 PR-check test expects 'Review bots: not run'. HARNESS_VERSION stays 6: skill-fixture.ts and llm-judge.ts are recorded non-behavioral for every other case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the cross-skill judge's rubric to one renamed bullet Name review-bot-history.md beside the older greptile-history.md so the calibration corpus still matches, and keep the REMOTE_SLUG bullet. One live 3-sample panel at this tree: consistent 3/3, scores 4/4/4. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: H1 fragment passes prose lint Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: name the H1 fragment after its branch; TODOS drops #356 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: /review's PR type step in docs/skills.md (#356) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-policy: review_exclude_paths key and loadPolicyAt (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: apply the base policy's review_exclude_paths and the reviewer diff ceiling (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * receipt: bind review_exclude in write, carry and the queue (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs, tests and changelog for review_exclude_paths (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (binary by bytes, links kept, untracked files out of the selection) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep binary files at code paths under review_exclude_paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the reasons that keep a binary file under review_exclude_paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep every review_exclude_paths candidate when the gate scan is unknown; docs example names its version Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the NUL fixture clear of the sync-spawn tripwire Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: split the ceiling sentence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep a live binary file whose bytes cannot be read; tests render outside voices from a clean checkout and read the ship fixture text Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review-exclude): explicit +x for the census script; skip the chmod-000 read case on Windows and root Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * refactor: move loadPolicyAt to lib/ship-policy-at.ts so lib/ship-policy.ts stays under the 800-line module cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: refuse a base that contains HEAD; skip a self-pointing origin/HEAD (GST-774) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: regenerate gstack-doctor-components.sh for BASE_CONTAINS_HEAD Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: land resumes a merged merge-only PR instead of refusing BASE_CONTAINS_HEAD (GST-774 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for GST-774 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: troubleshooting names diff-scope base_contains_head and the self-pointing no_base case (GST-774 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: repin the reviewer briefs to GST-508's no-loss-tested bytes ship/sections/review-army.md, ship/sections/adversarial.md and lib/review-depth.ts now pin the bytes the GST-508 preregistered no-loss replay ran at ef2760f (handoffs/GSTA-202/noloss-results.md in the GStack Minder drive): Arm V 33/41 vs R 33/41, delta 4, exact sign flip, p_NI 0.0279 (pass). The merge of main 83ae96e leaves these three files byte-identical to ef2760f, and an Arm P unit (3593) renders byte-identical briefs at ef2760f and at the merge. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: GST-774 review round 1 — merged resume depth, aliased and detached PR checkouts, develop/trunk defaults Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: Step 20 says a metrics row, keeping DRIVER.md at 12 KB after the main merge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ship-parts resend case leaves room for the first phase-3 part after the Step 9 targeted-record sentence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: /ship delivers review/checklist.md in two parts so checklist v1 fits the 24576-byte part cap (#3670) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: drop the GST-407 and GST-430 fragments, already released on main (stacked history) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave review-53cfb5bfe011: lib/ship-policy.ts back to 800 lines (module-size ratchet after merging #3744 and #3795) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave review-53cfb5bfe011: /ship pass 1 fixes - lib/ship-receipt-depth.ts: a merged resume (land after a true merge) no longer recomputes the review_exclude digest over the emptied range, so it lands instead of RECEIPT_INVALID (failing-first land test with review_exclude_paths active). - bin/gstack-review-dispatch render: a standalone targeted render under an active review_exclude_paths refuses and points at the pass manifest voices stage, instead of writing a brief with no selection that leaves verify dirty (failing-first test). - test/default-base.test.ts: #3795 expectations follow main R51 depth (light for an 80-line code diff; runtime_lines=80 still proves the real base). - test/review-dispatch.test.ts: the GST-897 fingerprint test renders from the clean checkout, like its neighbor, because CI rebuilds the tracked lib/diagram-render/dist/diagram-render.html and the reviewer diff ceiling refused the ROOT render. * wave review-53cfb5bfe011: /ship pass 2 fixes - lib/review-exclude.ts: binaryContent reads each blob's first 8,000 bytes (git cat-file blob, 8 KB buffer) instead of one --batch read capped at 256 MB, so head mode (receipt, carry, queue) and the plan agree past 256 MB of blobs; an unreadable blob is kept, as in live mode. Failing-first test with two 270 MB blobs. - lib/review-bots.ts: a file-level bot review comment (subject_type file, no line position) is triaged instead of read as outdated. Failing-first test. - test/ship-queue-land.test.ts: the merged-resume exclusion test runs its gate scan under the fixture state root, not the real home (CI free-suite 6 home containment). * fix: merge follow-ups for main 3d99c22: origin/* stays qualified in diff-scope's twin step, retro skeleton back under its cap, combined no-loss pin GST-508+R51+GST-911 * fix(review): receipt write gate-scans whenever the base declares review_exclude_paths; collectLive never re-qualifies origin/ (#3852 /ship red-team#1, outside-voice#1) * test: the docs-only receipt digest test keeps its gate scan out of the real home (free-suite shard 5 FREE_SUBSHARD_REJECTED) * fix(merge fc292e22f): tentative REVIEW_EXCLUDE_KEY_VERSION, classify targeted.json, render main's new outside-voice test from a clean checkout - lib/ship-policy.ts: REVIEW_EXCLUDE_KEY_VERSION 1.93.0.0 -> 1.94.0.0, the next-minor tentative form stamp_paths moves (docs/ship-policy.md, #3512); 1.93.0.0 is a released heading that never names the key. - test/gstack-clean-producers.test.ts (new from main): KEPT gains targeted.json, the wave's --review-targeted input. - test/review-dispatch.test.ts: main's wrong-tree test rendered the outside voice from ROOT; a CI build that dirties ROOT pushes that diff past the wave's REVIEW_DIFF_MAX_BYTES ceiling, so render refused and verify saw no brief (exit 3). It now runs in cleanCheckout like the wave's other outside-voice tests. * test(merge 68410828f): GST-967's brief-body pin carries the wave's GST-508 placeholders The merged sectionTexts differ from main's by exactly the wave's GST-508 exclude-pathspec lines (merged minus main equals wave minus fc292e22f); the 1,440-case plan grid still hashes to main's MAIN_PLANS. Checks: GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md. * ship: CI carries mapped lanes, --reuse tree cache, early push, finish/accept split (GSTA-258 A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: name the ship and pregate bins in prose so skill-bin-drift does not read a bin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * size-ledger: re-record ship after the tests.md wording fix Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * pregate: a carried run clears the remote gate through remote_gate_runs, so a paid-only remainder is not a dead end Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * merge cleanup: drop wave fragments main already consumed and a duplicated KEPT entry Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): record-ci verifies the CI run itself; finish's deferred preflight keeps pregate.json /ship pass 1 findings red-team#1 and specialist-batched#1 (GSTA-258 piece A). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(pregate): CI carry is gstack's default remote gate route; the VM is opt-in (plan choice F1) The uncleared remote_gate obligation names the CI route first when the base declares remote_gate_runs. docs/pregate.md says how gstack clears it, including the pull_request merge-ref tree mismatch. GSTA-258 piece A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (round 2) record-ci records the base policy that mapped the lane; the CI-first remote gate hint respects --no-ci-carry and reruns the whole pregate; changelog and docs say what the tool does. GSTA-258 piece A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CI carry in CONTRIBUTING, recommended-harness and the pregate usage line (ship docs audit) 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> --- .gstack/pregate.json | 3 + CHANGELOG.md | 9 + CONTRIBUTING.md | 3 +- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-ci-carry | 77 +++++++ bin/gstack-evidence | 166 ++++++++++++++- bin/gstack-pregate | 8 +- bin/gstack-ship-receipt | 8 +- docs/pregate.md | 119 ++++++++++- docs/recommended-harness.md | 4 +- lib/ci-carry.ts | 132 ++++++++++++ lib/headless-artifacts.ts | 3 + lib/pregate/checks/lanes.ts | 16 +- lib/pregate/config.ts | 16 +- lib/pregate/index.ts | 58 +++++- lib/pregate/types.ts | 2 + lib/ship-bin-common.ts | 55 ++++- lib/ship-preflight.ts | 13 +- lib/ship-receipt.ts | 3 +- lib/ship-release.ts | 96 ++++++++- package.json | 2 +- ship/DRIVER.md | 17 +- ship/DRIVER.md.tmpl | 17 +- ship/sections/tests.md | 16 ++ ship/sections/tests.md.tmpl | 16 ++ test/ci-carry.test.ts | 111 ++++++++++ test/evidence-reuse.test.ts | 141 +++++++++++++ test/helpers/fake-gh-runs.ts | 45 ++++ test/pregate-carry-gate.test.ts | 98 +++++++++ test/ship-ci-carry.test.ts | 194 ++++++++++++++++++ .../size-ledger/ship/gsta-258-a-ci-carry.json | 4 + 32 files changed, 1395 insertions(+), 61 deletions(-) create mode 100755 bin/gstack-ci-carry create mode 100644 lib/ci-carry.ts create mode 100644 test/ci-carry.test.ts create mode 100644 test/evidence-reuse.test.ts create mode 100644 test/helpers/fake-gh-runs.ts create mode 100644 test/pregate-carry-gate.test.ts create mode 100644 test/ship-ci-carry.test.ts create mode 100644 test/size-ledger/ship/gsta-258-a-ci-carry.json diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 5d1caa4f3..1dded7b5f 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -27,6 +27,9 @@ "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'", "skipped": "bun --no-env-file --config=/dev/null -e 'import { isPaidTestFile } from \"./test/helpers/paid-test-set.ts\"; import * as fs from \"node:fs\"; for (const f of fs.readFileSync(process.env.PREGATE_FILES_FILE, \"utf8\").split(/\\r?\\n/)) if (f && isPaidTestFile(f)) console.log(f);'" }, + "ship_lanes_from": { + "bun run test": { "profiles": ["ci-reuse/free-tests"], "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'" } + }, "warn": [], "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b27b94ae1..945e76db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.94.0.0] - 2026-11-03 + +- CI is now the one full test run. When a verified CI run already passed at the exact, clean tree, `gstack-pregate`'s tests stage and /ship's test lanes use it instead of running the suite again (GSTA-258, R65 item 1). The tests stage looks up the newest verified run at HEAD under the base branch's `run_profiles` by itself (`CI_CARRY: accepted …`; `--no-ci-carry` turns it off). A /ship lane carries only when the base branch's `.gstack/pregate.json` maps its exact command in the new `ship_lanes_from` key, and only when the run's `tree.json` covers the lane's inventory; gstack maps `bun run test` to its Free Tests run. A carried lane prints `LANE: