fix(ship): a slot restamp after the docs audit keeps finish's docs check fresh (GST-906)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 02:18:08 +00:00
1 parent a563f47679
commit cf39e6a30c
2 files changed
+48 -2

No files matched your search

+19 -2
View File
@@ -11,6 +11,8 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { normalizeChangelog } from './changelog-check';
import { versionAtRef } from './restamp';
import { classifyPath } from './review-depth';
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
@@ -217,7 +219,11 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
return by;
}
/** Step 16 stage 3. Test code, paths only the base moved, and --docs-requested fixes the audit's own result asked for do not stale the audit (GST-391). */
/**
* Step 16 stage 3. Test code, paths only the base or a stamp moved, a slot restamp after the audit (the CHANGELOG's heading
* move or re-heading, the version file's version, each against the audited bytes; GST-906), and --docs-requested fixes the
* audit's own result asked for do not stale the audit (GST-391).
*/
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, r: Report): boolean {
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000);
let stale: string[] | null = null;
@@ -253,7 +259,18 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
if (!atAudit || (nowDiff && nowDiff.status !== 0)) { r.stop(`git could not list the paths the branch touched against ${baseRef}; documentation freshness is unknown (stage 3)`); return false; }
const branchAtAudit = new Set([...atAudit, ...record.staged, ...record.unstaged, ...record.untracked]);
const branchNow = new Set([...(nowDiff ? nul(nowDiff.stdout) : []), ...nul(git(cwd, ['ls-files', '-z', '--others', '--exclude-standard']).stdout)]);
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] };
const changelog = shipPolicy(cwd, base)?.changelog;
const versionFile = versionAtRef(cwd, 'HEAD');
const restamped = stale.filter(p => {
const isChangelog = p === changelog;
if (!isChangelog && !('rel' in versionFile && p === versionFile.rel)) return false;
const audited = typeof record.hashes?.[p] === 'string' ? git(cwd, ['cat-file', 'blob', record.hashes[p]]) : null;
if (audited?.status !== 0) return false;
let now: string;
try { now = fs.readFileSync(path.join(cwd, p), 'utf8'); } catch { return false; }
return isChangelog ? normalizeChangelog(audited.stdout) === normalizeChangelog(now) : !!parseVersion(audited.stdout.trim()) && !!parseVersion(now.trim());
});
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] };
let audit: any = null;
try { audit = resultFile ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null; } catch { audit = null; }
for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q))) {
+29
View File
@@ -525,3 +525,32 @@ describe('preflight carry route for an adjacent base edit (GST-442)', () => {
});
});
describe('a CHANGELOG re-heading after the docs audit (GST-906, GBRA-258, gbrain #8372)', () => {
// Value: protects=a slot restamp after the Step 14.5 snapshot that only re-heads the branch's CHANGELOG entry
// ([Unreleased], or its old version after a lost slot, moved to `## [<version>] - <date>`, with the audited VERSION moved
// in the second case) keeps the audit fresh, while an entry edit beside the re-heading, or audited bytes git does not
// hold, stays stale; fails_when=finish stops with DOCS: stale (CHANGELOG.md) or (VERSION) on a pure restamp (recovery
// then needs a third audit or a named risk) or waves an entry edit through; seam=lib/ship-release.ts docsFresh;
// no_claim=the restamp writer
test('a re-heading alone keeps the audit fresh; an entry edit or unheld audited bytes stays stale', () => {
const policy = JSON.stringify({ bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'] });
const log = (heading: string, entry = '- b ships') => `# Changelog\n\n${heading}\n\n${entry}\n\n## [1.0.0.0] - 2026-10-30\n\n- base entry\n`;
const cases = [
['[Unreleased] re-headed', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), true, 'fresh'],
['old version re-headed after a lost slot', '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), '1.0.2.0', log('## [1.0.2.0] - 2026-11-02'), true, 'fresh'],
['re-heading plus an entry edit', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01', '- b ships fast'), true, 'stale'],
['audited bytes never committed or staged', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), false, 'stale'],
] as const;
for (const [name, audited, auditedLog, stamped, stampedLog, committed, verdict] of cases) {
const f = fx({ '.gstack/ship-policy.json': policy, 'CHANGELOG.md': '# Changelog\n\n## [1.0.0.0] - 2026-10-30\n\n- base entry\n' });
const reviewed = wtree(f);
f.write({ VERSION: `${audited}\n`, 'CHANGELOG.md': auditedLog });
if (committed) f.commit('Steps 12-13: version and CHANGELOG');
const post = snapshot(f);
f.write({ VERSION: `${stamped}\n`, 'CHANGELOG.md': stampedLog }); f.commit('slot restamp');
const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
if (verdict === 'fresh') expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 0, expect.stringContaining('DOCS: fresh against the Step 14.5 post-audit snapshot')]);
else expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 3, 'DOCS: stale (CHANGELOG.md)']);
}
});
});