mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
fix(evals): R2 normalises release outputs by stamp pattern only; compares stamp-file modes; unreadable blobs refuse
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
46bd939f0d
commit
6e6f6e75de
2 files changed
+76
-27
No files matched your search
+48
-25
@@ -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. */
|
||||
|
||||
+28
-2
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user