diff --git a/scripts/gate-reuse.ts b/scripts/gate-reuse.ts index c75f648cc..6fbba15f1 100644 --- a/scripts/gate-reuse.ts +++ b/scripts/gate-reuse.ts @@ -41,6 +41,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { receiptDigest } from '../lib/ship-queue'; import { isUnavailable, loadPolicyFromBase, matchesAny, type ShipPolicy } from '../lib/ship-policy'; +import { rewriteStamps } from '../lib/restamp'; import { buildReceipt } from '../lib/tree-receipt'; import { acceptedRun, ghFailureReason, runGh, type GhRunner, type WorkflowRun } from './recover-receipts'; import type { PaidRunManifest } from './lib/paid-plan'; @@ -152,8 +153,8 @@ export function testedBase(git: Git, tested: string, head: string): { base: stri } /** The files whose differences count as stamps under the base's policy. */ -function stampSet(policy: ShipPolicy): { version: string; changelog: string | null; releaseOutputs: string[] } { - return { version: 'VERSION', changelog: policy.changelog ?? null, releaseOutputs: policy.release_outputs }; +function stampSet(policy: ShipPolicy): { version: string; changelog: string | null; releaseOutputs: string[]; patterns: string[] } { + return { version: 'VERSION', changelog: policy.changelog ?? null, releaseOutputs: policy.release_outputs, patterns: policy.stamp_patterns }; } /** A version or [Unreleased] heading, which a restamp rewrites; any other heading is content. */ @@ -193,36 +194,58 @@ export function ownDiffSignature(git: Git, mergeBase: string, head: string, stam if (!r.ok) return { error: `git diff ${mergeBase.slice(0, 12)} ${head.slice(0, 12)} failed (${r.out.split('\n')[0]})` }; patch = r.out; } - const blob = (rev: string, file: string) => { const r = git(['show', `${rev}:${file}`]); return r.ok ? r.out : null; }; + const stampPaths = changed.filter(file => !others.includes(file)); + let modes: string[] = []; + if (stampPaths.length) { + const raw = git(['diff', '--raw', '-z', '--no-renames', mergeBase, head, '--', ...stampPaths.map(file => `:(literal)${file}`)]); + if (!raw.ok) return { error: `git diff --raw ${mergeBase.slice(0, 12)} ${head.slice(0, 12)} failed (${raw.out.split('\n')[0]})` }; + const fields = raw.out.split('\u0000'); + for (let i = 0; i + 1 < fields.length; i += 2) { + const [oldMode, newMode] = fields[i]!.replace(/^:/, '').split(' '); + if (oldMode !== newMode) modes.push(`${fields[i + 1]} ${oldMode} ${newMode}`); + } + modes = modes.sort(); + } + const blob = (rev: string, file: string): string | null => { + const r = git(['show', `${rev}:${file}`]); + if (r.ok) return r.out; + const listed = git(['ls-tree', '--name-only', rev, '--', `:(literal)${file}`]); + if (!listed.ok || listed.out.trim() !== '') throw new Error(`cannot read ${file} at ${rev.slice(0, 12)}`); + return null; + }; const version = (rev: string) => blob(rev, stamps.version)?.trim() || null; const unstamped = (rev: string, file: string) => { const text = blob(rev, file); const v = version(rev); - return text === null ? null : v ? text.split(v).join('\u0000VERSION') : text; + return text === null ? null : v ? rewriteStamps(text, v, '\u0000VERSION', stamps.patterns).text : text; }; - const manifest = (text: string | null) => { - if (text === null) return null; - try { const parsed = JSON.parse(text); delete parsed.version; return JSON.stringify(parsed); } catch { return text; } - }; - const [pkgBase, pkgHead] = [manifest(blob(mergeBase, 'package.json')), manifest(blob(head, 'package.json'))]; - const releases = changed.filter(isRelease).sort().flatMap(file => { - const [before, after] = [unstamped(mergeBase, file), unstamped(head, file)]; - return before === after ? [] : [[file, before, after]]; - }); - let changelog = null; - if (stamps.changelog && changed.includes(stamps.changelog)) { - const [before, after] = [changelogParts(blob(mergeBase, stamps.changelog)), changelogParts(blob(head, stamps.changelog))]; - const removed = [...before.sections]; - const added: string[] = []; - for (const section of after.sections) { - const i = removed.indexOf(section); - if (i >= 0) removed.splice(i, 1); - else added.push(section); + try { + const manifest = (text: string | null) => { + if (text === null) return null; + try { const parsed = JSON.parse(text); delete parsed.version; return JSON.stringify(parsed); } catch { return text; } + }; + const [pkgBase, pkgHead] = [manifest(blob(mergeBase, 'package.json')), manifest(blob(head, 'package.json'))]; + const releases = changed.filter(isRelease).sort().flatMap(file => { + const [before, after] = [unstamped(mergeBase, file), unstamped(head, file)]; + return before === after ? [] : [[file, before, after]]; + }); + let changelog = null; + if (stamps.changelog && changed.includes(stamps.changelog)) { + const [before, after] = [changelogParts(blob(mergeBase, stamps.changelog)), changelogParts(blob(head, stamps.changelog))]; + const removed = [...before.sections]; + const added: string[] = []; + for (const section of after.sections) { + const i = removed.indexOf(section); + if (i >= 0) removed.splice(i, 1); + else added.push(section); + } + changelog = { preamble: before.preamble === after.preamble ? null : [before.preamble, after.preamble], added: added.sort(), removed: removed.sort() }; } - changelog = { preamble: before.preamble === after.preamble ? null : [before.preamble, after.preamble], added: added.sort(), removed: removed.sort() }; + const body = { patch, modes, package: pkgBase === pkgHead ? null : [pkgBase, pkgHead], releases, changelog }; + return { sha256: createHash('sha256').update(JSON.stringify(body)).digest('hex') }; + } catch (err) { + return { error: (err as Error).message }; } - const body = { patch, package: pkgBase === pkgHead ? null : [pkgBase, pkgHead], releases, changelog }; - return { sha256: createHash('sha256').update(JSON.stringify(body)).digest('hex') }; } /** slices-report: what this executed run proved, or why it binds nothing. */ diff --git a/test/gate-reuse.test.ts b/test/gate-reuse.test.ts index 77e523c6e..873c85e30 100644 --- a/test/gate-reuse.test.ts +++ b/test/gate-reuse.test.ts @@ -84,6 +84,18 @@ beforeAll(() => { sha.rebasedDigest = commit(work, 'rebased-digest', sha.rebased, { 'agents-digest/gstack-AGENTS.md': '# gstack digest v1.0.1.0 — regenerate after upgrading\n\nbody\nhand-written instruction\n' }, 'rebased plus release-output content'); sha.rebasedDrop = commit(work, 'rebased-drop', sha.rebased, { 'CHANGELOG.md': ['# Changelog', '', ...entry].join('\n') }, 'rebased plus a deleted CHANGELOG section'); sha.rebasedPreamble = commit(work, 'rebased-preamble', sha.rebased, { 'CHANGELOG.md': changelog(entry).replace('# Changelog', '# Changelog (edited)') }, 'rebased plus a CHANGELOG preamble edit'); + const protocol = (version: string, protocolVersion: string) => ({ 'agents-digest/gstack-AGENTS.md': '# gstack digest v' + version + ' — regenerate after upgrading\n\nbody\ncompatible with protocol ' + protocolVersion + '\n' }); + sha.gatedProtocol = commit(work, 'gated-protocol', sha.base, { ...files('1.0.1.0', protocol('1.0.1.0', '1.0.1.0')), 'CHANGELOG.md': changelog(entry) }, 'a digest line that repeats the version as content'); + sha.restampProtocol = commit(work, 'restamp-protocol', sha.moved, { ...files('1.0.2.0', protocol('1.0.2.0', '1.0.2.0')), 'CHANGELOG.md': changelog(['## [1.0.2.0] - 2026-10-03', '', '- the feature', '']) }, + 'rebased restamp that also changes the content line'); + sha.restampProtocolKept = commit(work, 'restamp-protocol-kept', sha.moved, { ...files('1.0.2.0', protocol('1.0.2.0', '1.0.1.0')), 'CHANGELOG.md': changelog(['## [1.0.2.0] - 2026-10-03', '', '- the feature', '']) }, + 'rebased restamp that keeps the content line'); + git(work, 'checkout', '-q', '-B', 'rebased-mode', sha.rebased); + git(work, 'update-index', '--chmod=+x', 'package.json'); + git(work, 'commit', '-q', '-m', 'rebased plus a package.json mode change'); + git(work, 'push', '-q', 'origin', 'rebased-mode'); + git(work, 'reset', '-q', '--hard'); + sha.rebasedMode = git(work, 'rev-parse', 'HEAD'); sha.mainTouch = commit(work, 'main-touch', sha.base, { 'src/feature.ts': 'export const feature = 0;\n// main note\n' }, 'main edits the file the PR edits'); sha.touchRebased = commit(work, 'touch-rebased', sha.mainTouch, { ...files('1.0.1.0', { 'src/feature.ts': 'export const feature = 1;\n// main note\n' }), 'CHANGELOG.md': changelog(entry) }, 'the feature, rebased onto a main that edited its file'); @@ -206,6 +218,14 @@ describe('find: main moved (R2)', () => { } }); + test('a version-equal content line in a release output: only the stamp is normalised, so keeping the line reuses and changing it does not', () => { + const gated = binding({ head_sha: sha.gatedProtocol }); + expect(find(sha.restampProtocolKept!, [run(300, sha.gatedProtocol!)], { 300: gated }, { baseSha: sha.moved }).candidate?.kind).toBe('main-moved'); + const changed = find(sha.restampProtocol!, [run(300, sha.gatedProtocol!)], { 300: gated }, { baseSha: sha.moved }); + expect(changed.candidate).toBeNull(); + expect(changed.text).toContain("the PR's own diff (excluding stamp files) differs"); + }); + test('a restamp on a moved main: stamp files are outside the own-diff compare', () => { const { candidate } = find(sha.restamp!, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }); expect(candidate?.kind).toBe('main-moved'); @@ -236,8 +256,14 @@ describe('find: negative controls (each forces the full gate)', () => { 'stamp-classified files outside the version file, package.json, CHANGELOG and release outputs: bun.lock'); }); - test('main moved and the own diff changed: a code byte, CHANGELOG entry text, a package.json field, release-output content, a deleted CHANGELOG section, a CHANGELOG preamble edit', () => { - for (const head of [sha.rebasedCode!, sha.rebasedEntry!, sha.rebasedPkg!, sha.rebasedDigest!, sha.rebasedDrop!, sha.rebasedPreamble!]) { + test('main moved and the own diff changed: a code byte, CHANGELOG entry text, a package.json field', () => { + for (const head of [sha.rebasedCode!, sha.rebasedEntry!, sha.rebasedPkg!]) { + refused(find(head, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }), "the PR's own diff (excluding stamp files) differs"); + } + }); + + test('main moved and a stamp file changed beyond its stamp: release-output content, a deleted CHANGELOG section, a CHANGELOG preamble edit, a package.json mode', () => { + for (const head of [sha.rebasedDigest!, sha.rebasedDrop!, sha.rebasedPreamble!, sha.rebasedMode!]) { refused(find(head, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }), "the PR's own diff (excluding stamp files) differs"); } });