fix(evals): R2 own-diff signature covers release-output content and CHANGELOG deletions and preamble

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-18 04:19:20 +00:00
1 parent 00f5b5e2c9
commit 46bd939f0d
3 files changed
+64 -23

No files matched your search

+3 -2
View File
@@ -1175,8 +1175,9 @@ GREEN with reconcile exit 0, a successful run and no manually accepted
merge-base, excluding stamp files, is byte-identical to the gated head's
(`git diff --binary --full-index`, so pre-image blob ids are compared and a
main change to a file the PR also changes is a difference; `package.json`
compared without its version; the CHANGELOG by the multiset of section bodies
the PR added, headings excluded).
compared without its version; release outputs with each revision's version
replaced; the CHANGELOG by its preamble and the sections the PR added and
removed, dropping only version or `[Unreleased]` headings).
Finally this head's own plan must have the same selection fingerprint (cases,
statuses, trial plans, profile), so a main move that changes what the PR gate
+56 -19
View File
@@ -39,9 +39,8 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseChangelog } from '../lib/changelog-check';
import { receiptDigest } from '../lib/ship-queue';
import { isUnavailable, loadPolicyFromBase, type ShipPolicy } from '../lib/ship-policy';
import { isUnavailable, loadPolicyFromBase, matchesAny, type ShipPolicy } from '../lib/ship-policy';
import { buildReceipt } from '../lib/tree-receipt';
import { acceptedRun, ghFailureReason, runGh, type GhRunner, type WorkflowRun } from './recover-receipts';
import type { PaidRunManifest } from './lib/paid-plan';
@@ -157,34 +156,72 @@ function stampSet(policy: ShipPolicy): { version: string; changelog: string | nu
return { version: 'VERSION', changelog: policy.changelog ?? null, releaseOutputs: policy.release_outputs };
}
/** A version or [Unreleased] heading, which a restamp rewrites; any other heading is content. */
const STAMP_HEADING = /^## \[(?:Unreleased|\d+(?:\.\d+)+)\](?: - \d{4}-\d{2}-\d{2})?\s*$/;
/** The CHANGELOG as its preamble and the multiset of its sections, each without a stamp heading. */
function changelogParts(text: string | null): { preamble: string; sections: string[] } {
if (text === null) return { preamble: '\u0000missing', sections: [] };
const chunks: string[][] = [[]];
for (const line of text.split('\n')) {
if (line.startsWith('## ')) chunks.push([]);
chunks[chunks.length - 1]!.push(line);
}
const [preamble, ...sections] = chunks;
return { preamble: preamble!.join('\n'), sections: sections.map(lines => (STAMP_HEADING.test(lines[0]!) ? lines.slice(1) : lines).join('\n').replace(/\n+$/, '')) };
}
/**
* The PR's own diff from its merge-base with stamp files taken out: the full
* binary patch of every other path (pre-image blob ids included, so a file
* main also changed under the PR is a difference), package.json without its
* version, and the CHANGELOG sections the PR added, without their headings.
* The PR's own diff from its merge-base with stamps taken out. Every changed
* path that is not a stamp file contributes its full binary patch (pre-image
* blob ids included, so a file main also changed under the PR is a
* difference). Stamp files contribute everything except the stamp: the root
* package.json without its version, each release output with its revision's
* version replaced (its pre-image too, when anything else changed), and the
* CHANGELOG's preamble plus the sections the PR added and removed, with only
* version or [Unreleased] headings dropped. The version file is the stamp.
*/
export function ownDiffSignature(git: Git, mergeBase: string, head: string, stamps: ReturnType<typeof stampSet>): { sha256: string } | { error: string } {
const excluded = [stamps.version, 'package.json', ...(stamps.changelog ? [stamps.changelog] : []), ...stamps.releaseOutputs];
const patch = git(['diff', '--no-ext-diff', '--no-renames', '--no-color', '--binary', '--full-index', mergeBase, head, '--', '.',
...excluded.map(file => `:(exclude)${file}`)]);
if (!patch.ok) return { error: `git diff ${mergeBase.slice(0, 12)} ${head.slice(0, 12)} failed (${patch.out.split('\n')[0]})` };
const names = git(['diff', '--name-only', '-z', '--no-renames', mergeBase, head]);
if (!names.ok) return { error: `git diff ${mergeBase.slice(0, 12)} ${head.slice(0, 12)} failed (${names.out.split('\n')[0]})` };
const changed = names.out.split('\u0000').filter(Boolean);
const isRelease = (file: string) => matchesAny(stamps.releaseOutputs, file);
const others = changed.filter(file => file !== stamps.version && file !== 'package.json' && file !== stamps.changelog && !isRelease(file));
let patch = '';
if (others.length) {
const r = git(['diff', '--no-ext-diff', '--no-renames', '--no-color', '--binary', '--full-index', mergeBase, head, '--', ...others.map(file => `:(literal)${file}`)]);
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 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;
};
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 sections = (text: string | null) => text === null ? [] : parseChangelog(text).sections.map(s => s.lines.slice(1).join('\n').replace(/\n+$/, ''));
const own: string[] = [];
if (stamps.changelog) {
const before = sections(blob(mergeBase, stamps.changelog));
for (const body of sections(blob(head, stamps.changelog))) {
const i = before.indexOf(body);
if (i >= 0) before.splice(i, 1);
else own.push(body);
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() };
}
const body = { patch: patch.out, package: pkgBase === pkgHead ? null : [pkgBase, pkgHead], changelog: own.sort() };
const body = { patch, package: pkgBase === pkgHead ? null : [pkgBase, pkgHead], releases, changelog };
return { sha256: createHash('sha256').update(JSON.stringify(body)).digest('hex') };
}
+5 -2
View File
@@ -81,6 +81,9 @@ beforeAll(() => {
sha.rebasedCode = commit(work, 'rebased-code', sha.rebased, { 'src/feature.ts': 'export const feature = 3;\n' }, 'rebased plus a code byte');
sha.rebasedEntry = commit(work, 'rebased-entry', sha.rebased, { 'CHANGELOG.md': changelog(['## [1.0.1.0] - 2026-10-02', '', '- the feature, reworded', '']) }, 'rebased plus entry text');
sha.rebasedPkg = commit(work, 'rebased-pkg', sha.rebased, { 'package.json': JSON.stringify({ name: 'fixture', version: '1.0.1.0', scripts: { test: 'bun test --bail' } }, null, 2) + '\n' }, 'rebased plus a package script');
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');
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');
@@ -233,8 +236,8 @@ 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', () => {
for (const head of [sha.rebasedCode!, sha.rebasedEntry!, sha.rebasedPkg!]) {
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!]) {
refused(find(head, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }), "the PR's own diff (excluding stamp files) differs");
}
});