Merge origin/main and stamp v1.91.316.0 (train 46 slot 3, #3593)

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-29 10:13:28 +00:00
commit 99730eaf68
48 files changed
+1749 -174

No files matched your search

+40
View File
@@ -196,3 +196,43 @@ describe('Capy mirror', () => {
expect(read.state).toBe('present');
});
});
describe('import --autoplan --stack', () => {
function stackRepo(): string {
const d = repo('feature/one');
const g = (args: string[]) => expect(spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], { cwd: d, timeout: 30_000 }).status).toBe(0);
g(['commit', '-q', '--allow-empty', '-m', 'seed']);
g(['branch', 'feature/two']);
return d;
}
test('writes the same rows to every branch, each in its own record; a retry adds nothing', () => {
const run = tmp('appr-run-');
autoplanRun(run, [{ by: 'agent:GSTA-36', words: 'Garry: Do it', at: '2026-10-29T03:40:00.000Z', p1: 'a' }]);
const cwd = stackRepo(); const state = tmp('appr-state-');
const env = { GSTACK_STATE_ROOT: state };
const r = bin(['import', '--autoplan', run, '--by', 'Garry', '--stack', 'feature/one,feature/two'], { cwd, env });
expect(r.status, r.err).toBe(0);
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/one n=1 skipped=0 /m);
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/two n=1 skipped=0 /m);
expect(r.out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=2$/m);
for (const b of ['feature/one', 'feature/two']) expect(bin(['render', '--branch', b], { cwd, env }).out).toContain('Garry: Do it');
expect(bin(['import', '--autoplan', run, '--by', 'Garry', '--stack', 'feature/one,feature/two'], { cwd, env }).out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=0$/m);
});
test('one unknown branch writes nothing to any branch', () => {
const run = tmp('appr-run-');
autoplanRun(run, [{ by: 'agent:GSTA-36', words: 'rec', at: '2026-10-29T03:40:00.000Z', p1: 'a' }]);
const cwd = stackRepo(); const state = tmp('appr-state-');
const r = bin(['import', '--autoplan', run, '--stack', 'feature/one,feature/nope'], { cwd, env: { GSTACK_STATE_ROOT: state } });
expect(r.status).toBe(3);
expect(r.err).toContain('feature/nope');
expect(r.err).toContain('(APPROVAL_RECORD_NO_BRANCH)');
expect(bin(['render', '--branch', 'feature/one'], { cwd, env: { GSTACK_STATE_ROOT: state } }).out).not.toContain('Approve the plan');
});
test('--stack goes with import --autoplan and without --branch', () => {
const cwd = stackRepo();
expect(bin(['import', '--autoplan', tmp('appr-run-'), '--branch', 'feature/one', '--stack', 'feature/one'], { cwd }).status).toBe(2);
});
});
+2 -2
View File
@@ -113,7 +113,7 @@ describe('answer', () => {
}, 30_000);
test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => {
const out = gatedRun('reopen');
const out = gatedRun('reopen', ['--no-carry-forward']);
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
expect(r.status, r.stderr).toBe(0);
const runId = readRun(out).run;
@@ -166,7 +166,7 @@ describe('answer', () => {
}, 30_000);
test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
const out = gatedRun('eng-only', ['--max-rounds', '3']);
const out = gatedRun('eng-only', ['--max-rounds', '3', '--no-carry-forward']);
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
const runId = readRun(out).run;
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] });
+327
View File
@@ -0,0 +1,327 @@
/**
* Carry-forward (GSTA-155, lib/autoplan-carry.ts): a later gate revision that asks nothing new is answered with the
* owner's recorded words from the revision before it, and recorded as a carry; every other case prints why not and
* stays at the gate. A "do it" also covers new auto items that change no scope, cost or direction. A capped,
* unreviewed delta gets one reviewer on the delta first (CARRY_CHECK, gstack-autoplan check): High or above means no
* carry. Runs on the simulated Capy host.
*/
import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { isDoIt } from '../lib/autoplan-carry';
import { importAutoplan } from '../lib/approval-record';
import { renderCaptainBrief } from '../lib/captain-brief';
import { recHash, renderGate } from '../lib/gate-list';
import { loadBriefInputs } from '../lib/owner-brief';
import { SMALL_PLAN } from './helpers/autoplan-run-fixture';
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
setDefaultTimeout(30_000);
const r = capyRunner();
afterAll(() => r.cleanup());
let n = 0;
const NONE = { native: [], outside: [] };
const AGREE = {
native: [{ id: 'F1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
outside: [{ id: 'O1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
};
const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1');
const gateOf = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
const decisions = (out: string) => readJsonl(path.join(out, 'decisions.jsonl'));
const journal = (out: string) => readJsonl(path.join(out, 'attempts.jsonl'));
/** Gate 1 with one recommended plan change (uc1b), answered with the owner's words, then revised to take it. */
function answeredAndRevised(args: string[] = [], words = 'Garry: Do it', ceo: { native?: unknown[]; outside?: unknown[] } = AGREE) {
const dir = path.join(r.host.base, `carry${++n}`);
const plan = writePlan(dir);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]).status).toBe(0);
phaseVia(r, out, 'ceo', ceo);
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
const run = readRunJson(out).run;
const ans = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', recHashOf(out), '--by', 'agent:gstack-minder', '--owner-reply', words]);
expect(ans.status, ans.stderr).toBe(0);
expect(ans.stdout).toMatch(/^APPLY: /m);
const v2 = path.join(dir, 'plan-v2.md');
fs.writeFileSync(v2, V2);
const rev = r.ap(['revise', '--out', out, '--plan', v2, '--incorporates', 'uc1']);
expect(rev.status, rev.stderr).toBe(0);
return { dir, out, run, v2, rev };
}
const recHashOf = (out: string) => recHash(gateOf(out));
describe('carry-forward', () => {
test('a second gate with nothing new carries the owner\'s words: CARRIED, APPROVED, the record names its source', () => {
const s = answeredAndRevised();
phaseVia(r, s.out, 'ceo', NONE);
const eng = phaseVia(r, s.out, 'eng', NONE).outside;
expect(eng.status, eng.stderr).toBe(0);
expect(eng.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 words="Garry: Do it" by=agent:gstack-minder reason=no new choice; plan unchanged since the accepted changes$/m);
expect(eng.stdout).toMatch(/^APPROVED: gate_rev=2/m);
expect(eng.stdout).toContain('status=complete');
const p1 = decisions(s.out).find(d => d.gate_rev === 2 && d.label === 'P1');
expect(p1).toMatchObject({ chosen: 'a', owner_reply: 'Garry: Do it', answered_by: 'agent:gstack-autoplan', carried_gate_from: `${s.run}-p1`, carried_gate_rev: 1, carried_answered_by: 'agent:gstack-minder' });
expect(journal(s.out).find(e => e.event === 'gate_carried')).toMatchObject({ gate_rev: 2, from_gate_rev: 1 });
expect(journal(s.out).find(e => e.event === 'answered' && e.gate_rev === 1)).toMatchObject({ answer_class: 'plan-changes' });
expect(fs.readFileSync(path.join(s.out, 'decisions.tsv'), 'utf8')).toContain('carried gate_rev 2 from gate_rev 1');
expect(r.ap(['export', '--out', s.out]).status).toBe(0);
expect(r.validate(s.out).status).toBe(0);
const rows = importAutoplan(s.out, 'feature/x', 'Garry');
if ('code' in rows) throw new Error(rows.message);
const carried = rows.find(x => x.source.endsWith('gate_rev=2'))!;
expect(carried).toMatchObject({ words: 'Garry: Do it', outcome: 'approved' });
expect(carried.ask).toContain('gate_rev 2 carried forward from gate_rev 1');
expect(carried.by).toBe('Garry (words from gate_rev 1, relayed by agent:gstack-minder), carried forward by gstack-autoplan');
const loaded = loadBriefInputs(s.out);
if ('error' in loaded) throw new Error(String(loaded.error));
expect(renderCaptainBrief(loaded.inputs, { outDir: s.out }).text).toContain('**Nothing pending.** Carried forward from gate_rev 1: approved as reviewed under the owner\'s words “Garry: Do it”');
});
test('--no-carry-forward: the second gate goes to the owner', () => {
const off = answeredAndRevised(['--no-carry-forward']);
phaseVia(r, off.out, 'ceo', NONE);
const o = phaseVia(r, off.out, 'eng', NONE).outside;
expect(o.stdout).toMatch(/^CARRY: no \(carry-forward is off for this run \(--no-carry-forward\)\)$/m);
expect(o.stdout).toMatch(/^GATE: gate_rev=2 /m);
expect(o.stdout).toContain('status=gate_pending');
});
test('an edit to the plan file after the revise (an ELI5-only line) refuses the carry', () => {
const edited = answeredAndRevised();
fs.appendFileSync(edited.v2, '\nAn ELI5 sentence added after the revise.\n');
phaseVia(r, edited.out, 'ceo', NONE);
const e = phaseVia(r, edited.out, 'eng', NONE).outside;
expect(e.stdout).toMatch(/^CARRY: no \(the plan file plan-v2\.md changed after the owner's answer or the revise that took it\)$/m);
expect(readRunJson(edited.out).status).toBe('gate_pending');
});
test('a new scope challenge or an urgent finding refuses the carry', () => {
const scope = answeredAndRevised();
phaseVia(r, scope.out, 'ceo', NONE);
const s2 = phaseVia(r, scope.out, 'eng', { native: [{ id: 'F9', severity: 'High', title: 'Add a dead-letter queue', user_challenge: true, changes: 'scope', fix: 'dlq' }], outside: [] }).outside;
expect(s2.stdout).toMatch(/^CARRY: no \(new item \w+: Add a dead-letter queue/m);
const urgent = answeredAndRevised();
phaseVia(r, urgent.out, 'ceo', NONE);
const u = phaseVia(r, urgent.out, 'eng', { native: [{ id: 'F8', severity: 'Medium', title: 'Tokens logged in plain text', urgent: true, suggested_owner: 'security' }], outside: [] }).outside;
expect(u.stdout).toMatch(/^CARRY: no \(urgent finding open: Tokens logged in plain text\)$/m);
});
test('"do it" also covers a new packaging challenge; "rec" does not', () => {
const PKG = { native: [{ id: 'F7', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'own PR' }], outside: [] };
const doIt = answeredAndRevised();
phaseVia(r, doIt.out, 'ceo', NONE);
const d = phaseVia(r, doIt.out, 'eng', PKG).outside;
expect(d.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason="do it" covers \w+; plan unchanged/m);
expect(d.stdout).toMatch(/^CARRIED_UNDER_DO_IT: \w+ Split the backoff into its own PR .*\(the owner has not seen this item\)$/m);
expect(decisions(doIt.out).find(x => x.gate_rev === 2 && /Split the backoff/.test(x.title))).toMatchObject({ carried_under: 'do it' });
const loaded = loadBriefInputs(doIt.out);
if ('error' in loaded) throw new Error(String(loaded.error));
expect(renderCaptainBrief(loaded.inputs, { outDir: doIt.out }).text).toContain('Taken under "do it" without being shown to the owner: Split the backoff into its own PR.');
const rec = answeredAndRevised([], 'rec');
phaseVia(r, rec.out, 'ceo', NONE);
expect(phaseVia(r, rec.out, 'eng', PKG).outside.stdout).toMatch(/^CARRY: no \(new item \w+: Split the backoff/m);
});
test('an earlier answer in item tokens, or one that left an item unanswered, is not carried', () => {
const dir = path.join(r.host.base, `carry${++n}`);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
phaseVia(r, out, 'ceo', AGREE);
phaseVia(r, out, 'eng', NONE);
const g = gateOf(out);
const tokens = g.items.map((i: any) => `${i.id}${i.recommended}`).join(' ');
expect(r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', tokens, '--by', 'human']).status).toBe(0);
const v2 = path.join(dir, 'plan-v2.md');
fs.writeFileSync(v2, V2);
expect(r.ap(['revise', '--out', out, '--plan', v2, '--incorporates', 'uc1']).status).toBe(0);
phaseVia(r, out, 'ceo', NONE);
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toMatch(/^CARRY: no \(gate_rev 1 was answered with item tokens, not rec or "do it"\)$/m);
});
test('an answer that left an approval item pending is not carried', () => {
const dir = path.join(r.host.base, `carry${++n}`);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
phaseVia(r, out, 'ceo', AGREE);
phaseVia(r, out, 'eng', NONE);
const p1 = gateOf(out).items.find((i: any) => i.id === 'p1');
const ans = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', `p1${p1.recommended}`, '--by', 'human']);
expect(ans.status, ans.stderr).toBe(0);
expect(decisions(out).some(x => x.gate_rev === 1 && x.status === 'pending')).toBe(true);
const v2 = path.join(dir, 'plan-v2.md');
fs.writeFileSync(v2, V2);
expect(r.ap(['revise', '--out', out, '--plan', v2]).status).toBe(0);
phaseVia(r, out, 'ceo', NONE);
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toMatch(/^CARRY: no \(gate_rev 1 left \S+ unanswered\)$/m);
});
test('isDoIt reads the owner\'s words, not a Captain\'s', () => {
for (const w of ['Do it', 'do it.', 'Garry: Do it', 'Garry Tan: do it!']) expect(isDoIt(w)).toBe(true);
for (const w of ['rec', 'accept all', 'Captain: Do it', 'GStack Captain: do it', 'p3b: do it', "Captain: rec, Highs as build tasks under Garry's 'Do it'", 'Do it and also improve it']) expect(isDoIt(w)).toBe(false);
});
});
describe('the delta check before a carry', () => {
function capped() {
const s = answeredAndRevised(['--max-rounds', '1']);
expect(s.rev.stdout).toMatch(/^CAPPED: ceo used round 1 of 1/m);
expect(s.rev.stdout).toMatch(/^CARRY_CHECK: attempt=\S+-c2 items=uc\d+,uc\d+ prompt=\S+ result=\S+ model_family=not anthropic estimate=5\.00 cap=5\.00$/m);
expect(s.rev.stdout).toMatch(/^GATE: gate_rev=2 /m);
const run = readRunJson(s.out);
expect(run.status).toBe('gate_pending');
const check = run.carry.check;
const prompt = fs.readFileSync(check.prompt, 'utf8');
expect(prompt).toContain('+ ');
expect(prompt).toContain(`INPUT: carry-check ${check.delta_sha256}`);
return { ...s, check };
}
const result = (file: string, sha: string, findings: unknown[]) => fs.writeFileSync(file, `INPUT: carry-check ${sha}\n\nReviewed the delta.\n\n` + '```gstack-findings\n' + findings.map(f => JSON.stringify(f)).join('\n') + '\n```\n');
test('a clean delta check carries; the verdict is on the gate, the decisions and the trail', () => {
const s = capped();
result(s.check.result, s.check.delta_sha256, [{ id: 'F1', severity: 'Low', title: 'Wording' }]);
const same = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'anthropic/claude-opus-5-5']);
expect(same.status).toBe(3);
expect(same.stderr).toContain('MODEL_FAMILY_CONFLICT');
const ok = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toMatch(/^CARRY_CHECK: passed attempt=\S+ model=openai\/gpt-6\.1-sol findings=1 highest=Low$/m);
expect(ok.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason=delta check passed on uc\d+,uc\d+; plan unchanged/m);
expect(ok.stdout).toContain('status=complete');
expect(gateOf(s.out).carry_check).toMatchObject({ verdict: 'passed', model: 'openai/gpt-6.1-sol', max_severity: 'Low', findings: 1 });
expect(decisions(s.out).filter(x => x.gate_rev === 2 && x.carry_check).length).toBe(2);
expect(fs.readFileSync(path.join(s.out, 'decisions.tsv'), 'utf8')).toContain('delta check before carrying gate_rev 2');
expect(r.ap(['export', '--out', s.out]).status).toBe(0);
expect(r.validate(s.out).status).toBe(0);
});
test('a High finding, or no reviewer, leaves the gate for the owner', () => {
const s = capped();
result(s.check.result, s.check.delta_sha256, [{ id: 'F1', severity: 'High', title: 'The edit also drops the jitter' }]);
const bad = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
expect(bad.status, bad.stderr).toBe(0);
expect(bad.stdout).toMatch(/^CARRY: no \(delta check found High: The edit also drops the jitter\)$/m);
expect(bad.stdout).toMatch(/^GATE: gate_rev=2 /m);
const g = gateOf(s.out);
expect(g.carry_check).toMatchObject({ verdict: 'failed', max_severity: 'High' });
expect(g.items.filter((i: any) => i.cap?.kind === 'delta').every((i: any) => i.flags.some((f: string) => f.includes('The edit also drops the jitter')))).toBe(true);
const again = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--unavailable', 'x']);
expect(again.status).toBe(3);
const u = capped();
const none = r.ap(['check', '--out', u.out, '--attempt', u.check.attempt, '--unavailable', 'no other family online']);
expect(none.status, none.stderr).toBe(0);
expect(none.stdout).toMatch(/^CARRY: no \(delta check unavailable: no other family online\)$/m);
expect(JSON.parse(fs.readFileSync(path.join(u.out, 'spend.json'), 'utf8')).released.map((x: any) => x.attempt)).toContain(u.check.attempt);
});
// Value: protects=a delta check over --carry-check-cap or with the wrong --attempt never carries the owner's answer;
// fails_when=the cap comparison or the attempt guard is dropped or inverted; why_new=tests above run at the default cap
// with the right attempt only; seam=none; no_claim=real reviewer dispatch (simulated Capy host)
test('a check over --carry-check-cap refuses the carry; a wrong --attempt is refused', () => {
const over = answeredAndRevised(['--max-rounds', '1', '--carry-check-cap', '1']);
expect(over.rev.stdout).toMatch(/^CARRY: no \(delta check estimate 5\.00 is over the per-gate cap 1\.00 \(--carry-check-cap\)\)$/m);
expect(over.rev.stdout).not.toMatch(/^CARRY_CHECK:/m);
expect(readRunJson(over.out).status).toBe('gate_pending');
const s = capped();
result(s.check.result, s.check.delta_sha256, []);
const wrong = r.ap(['check', '--out', s.out, '--attempt', 'not-the-attempt', '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
expect(wrong.status).toBe(3);
expect(wrong.stderr).toContain('ATTEMPT_MISMATCH');
expect(readRunJson(s.out).status).toBe('gate_pending');
});
test('an owner answer at the gate voids a pending delta check and releases its reservation', () => {
const s = capped();
const ans = r.ap(['answer', '--out', s.out, '--gate-rev', '2', '--reply', 'p1c', '--by', 'human']);
expect(ans.status, ans.stderr).toBe(0);
expect(ans.stdout).toContain(`CARRY: no (delta check ${s.check.attempt} void: the owner answered gate_rev 2)`);
result(s.check.result, s.check.delta_sha256, []);
const late = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
expect(late.status).toBe(3);
expect(late.stderr).toContain('PHASE_NOT_AWAITING');
expect(readRunJson(s.out).status).not.toBe('complete');
expect(decisions(s.out).some(x => x.gate_rev === 2 && x.carried_gate_from)).toBe(false);
expect(JSON.parse(fs.readFileSync(path.join(s.out, 'spend.json'), 'utf8')).released.map((x: any) => x.attempt)).toContain(s.check.attempt);
});
test('a plan edited after the check was dispatched is not carried on that check', () => {
const s = capped();
fs.appendFileSync(readRunJson(s.out).active_plan, '\nA line the reviewer never saw.\n');
result(s.check.result, s.check.delta_sha256, []);
const c = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'openai/gpt-6.1-sol']);
expect(c.status, c.stderr).toBe(0);
expect(c.stdout).toContain('void: the plan changed after the delta check was dispatched');
expect(c.stdout).not.toMatch(/^CARRIED:/m);
expect(readRunJson(s.out).status).toBe('gate_pending');
});
test('--same-family binds a same-family reviewer and records why', () => {
const s = capped();
result(s.check.result, s.check.delta_sha256, []);
const ok = r.ap(['check', '--out', s.out, '--attempt', s.check.attempt, '--result', s.check.result, '--model', 'anthropic/claude-opus-5-5', '--same-family', 'no other family online']);
expect(ok.status, ok.stderr).toBe(0);
expect(gateOf(s.out).carry_check.model).toBe('anthropic/claude-opus-5-5 (same family: no other family online)');
});
});
describe('the review estimate', () => {
test('the first next prints the most the review can reserve', () => {
const dir = path.join(r.host.base, 'estimate');
const out = path.join(dir, 'run');
const first = r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']);
expect(first.stdout).toMatch(/^REVIEW_ESTIMATE: phases=ceo,eng voices=2\+2 rounds<=2 attempts<=8 per_voice=5\.00 reservations<=40\.00 \(a reservation total, not a spend ceiling/m);
expect(r.ap(['next', '--out', out]).stdout).not.toContain('REVIEW_ESTIMATE');
});
});
describe('gate eli5', () => {
const SECTIONS = '\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
function gated(name: string, text: string) {
const dir = path.join(r.host.base, name);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir, text), '--no-ui']).status).toBe(0);
phaseVia(r, out, 'ceo', NONE);
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
return out;
}
test('gate.json carries the plan\'s ELI5 and four sections, and page 1 of the render opens with them', () => {
const out = gated('eli5', SMALL_PLAN + '\n## ELI5\n\nRetries wait longer each time.\n' + SECTIONS);
const gate = gateOf(out);
expect(gate.eli5).toMatchObject({ summary: 'Retries wait longer each time.', before_after: 'Before: retries hammer. After: they back off.', why: 'Outages.', cause: 'Incident 7.', decisions: '1. Exponential backoff.' });
expect(gate.eli5.plan_file_sha256).toMatch(/^[0-9a-f]{64}$/);
const page = renderGate(gate).split('\n');
expect(page.slice(2, 4)).toEqual(['ELI5', 'Retries wait longer each time.']);
expect(page.indexOf('Before → after:')).toBeGreaterThan(3);
expect(page.indexOf('Before → after:')).toBeLessThan(page.findIndex(l => /^1\. \[/.test(l)));
const loaded = loadBriefInputs(out);
if ('error' in loaded) throw new Error(String(loaded.error));
const brief = renderCaptainBrief(loaded.inputs, { outDir: out });
expect(brief.missing).toEqual([]);
expect(brief.text.indexOf('### ELI5')).toBeLessThan(brief.text.indexOf('### Before → after'));
});
test('a plan without ## ELI5: the Captain brief is not ready and names it; the plain gate render does not ask for it', () => {
const out = gated('no-eli5', SMALL_PLAN + SECTIONS);
const page = renderGate(gateOf(out));
expect(page).not.toContain('CAPTAIN_BRIEF_INCOMPLETE');
expect(page).toContain('Before → after:');
const loaded = loadBriefInputs(out);
if ('error' in loaded) throw new Error(String(loaded.error));
const brief = renderCaptainBrief(loaded.inputs, { outDir: out });
expect(brief.missing).toEqual(['ELI5']);
expect(brief.text).toStartWith('**Not ready for approval:**');
});
test('a gate written before the eli5 field renders and briefs as before', () => {
const out = gated('legacy', SMALL_PLAN + SECTIONS);
const gate = gateOf(out);
delete gate.eli5;
fs.writeFileSync(path.join(out, 'gate.json'), JSON.stringify(gate, null, 2) + '\n');
expect(renderGate(gate)).not.toContain('ELI5');
const loaded = loadBriefInputs(out);
if ('error' in loaded) throw new Error(String(loaded.error));
expect(renderCaptainBrief(loaded.inputs, { outDir: out }).missing).toEqual([]);
});
});
+1 -1
View File
@@ -157,7 +157,7 @@ describe('gstack-owner-brief render --captain', () => {
describe('carried decisions at a cap gate (#3546)', () => {
const r = capyRunner();
afterAll(() => r.cleanup());
const SECTIONS = '\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
const SECTIONS = '\n## ELI5\n\nRetries wait longer each time.\n\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
const PLAN = SMALL_PLAN + SECTIONS;
const WORDS: Record<string, string> = { 'keep your direction': 'keep the plan as written', 'accept the change': "make the reviewer's change" };
+4
View File
@@ -19,6 +19,10 @@ afterAll(() => r.cleanup());
const PLAN = `# Retry backoff for the webhook worker
## ELI5
A failed webhook now waits longer before each retry, so the partner stops dropping them.
## Before → after
Before: a failed webhook retries every second until it is dropped. After: retries back off from 500 ms to 30 s.
+26
View File
@@ -393,6 +393,32 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
}
});
// #3571: /ship's docs step ran install.sh --for document-release from inside the run that
// holds the lease, the upgrader excluded its own ancestry, and the checkout moved mid-/ship.
// On Capy the leased pid is the long-lived capy-server, so the deferral ends by the lease's age.
test('install mode inside a run that holds a fresh workflow lease: the self-upgrade is deferred, HEAD stays and the trailer stays ok; once that lease is 120 minutes old the same install upgrades', () => {
const f = fixture({ cloneAt: 'B' });
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
const leaseDir = path.join(f.host.base, 'leases');
const script = [
'bash "$1" touch --pid $$ --root "$2" || exit 9',
'bash "$3" --for document-release; echo "exit=$?"',
'git -C "$2" rev-parse HEAD',
'"$5" -e "const t = Date.now() / 1000 - 121 * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "$4/$$"',
'bash "$3" --for document-release; echo "exit=$?"',
].join('\n');
const r = spawnSync('bash', ['-c', script, 'run', path.join(f.root, 'bin/gstack-lease'), f.root, BOOT, leaseDir, process.execPath], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_ROOT: f.root, GSTACK_CAPY_REPO: `file://${f.bare}`, WRAP_LOG: path.join(f.host.base, 'wrap.log'), GSTACK_LEASE_DIR: leaseDir }, timeout: 120_000 });
const [inRun, aged] = r.stdout.split(/^exit=0$/m);
expect(r.stdout.match(/^exit=0$/gm), r.stdout + r.stderr).toHaveLength(2);
expect(inRun).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease on this installation: pid \d+ \(0m bash\), so the upgrade waits until the lease is 120 minutes old; fix: .+bin\/gstack-upgrade once the run ends \(UPGRADE_BUSY\)\)$/m);
expect(inRun).not.toContain('SELF_UPGRADE: done');
expect(inRun).toMatch(/blocked=a gstack run in this session holds a workflow lease .+ \(UPGRADE_BUSY\)$/m);
expect(aged).toContain(f.b);
expect(aged).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
});
test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => {
const f = fixture({ cloneAt: 'B' });
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
+6
View File
@@ -193,6 +193,12 @@ describe('pregate config: sealed (CD4)', () => {
expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']);
expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']);
expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']);
expect(codes({ sealed: { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json', '**/protected/MANIFEST.json'], case_insensitive: true } })).toEqual([]);
expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']);
expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/*.json', 'sealed/*.*', '**/sealed/**.json', '**/sealed/*/MANIFEST.json', '**/sealed/*/transcript-*.jsonl', '.hidden'] } })).toEqual([0, 1, 2, 3, 4, 5].map(i => `PREGATE_CONFIG_INVALID:sealed.allow_paths[${i}]`));
expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']);
});
});
@@ -0,0 +1,97 @@
/**
* Two MEDIUM false positives from the gstack-evals runs (gstack 1.91.299-301):
* pii.phone.e164 on `4294967296` (2^32, a PRNG constant) and pii.email on
* `terminal-bench@2.0.json` (a package@version filename). Each guard is
* pinned in both directions: the false positive is clean, and the real shape
* next to it still reports.
*/
import { describe, test, expect } from "bun:test";
import { scan } from "../lib/redact-engine";
import { isNumericConstant, looksLikeAtFilename } from "../lib/redact-patterns";
const flags = (id: string, s: string): boolean =>
scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === id);
describe("pii.phone.e164 and bare numeric constants", () => {
// Value: protects=a bare power of two (or one less) and the common 32-bit hash/PRNG constants raise no phone finding; fails_when=the guard is
// removed and `4294967296` reports pii.phone.e164 again; why_new=no constant guard existed; seam=none (scan); no_claim=other constants not listed
test("powers of two, their max values and hash/PRNG constants are not phone numbers", () => {
for (const s of [
"const RANGE = 4294967296;",
"mask = 4294967295",
"INT32_MAX 2147483647",
"x % 2147483648",
"1073741824 bytes",
"2^49 is 562949953421312",
"h *= 2654435761",
"seed ^= 2654435769",
"a = 1103515245",
"basis 2166136261",
"mt init 1812433253",
"p = 4294967291",
]) {
expect([s, flags("pii.phone.e164", s)]).toEqual([s, false]);
}
expect(isNumericConstant("4294967296")).toBe(true);
expect(isNumericConstant("4294967297")).toBe(false);
});
// Value: protects=the same digits written as a phone number (a +, spaces, dashes, parens) and ordinary bare numbers still report; fails_when=the
// guard exempts by digits alone or by length, so +14294967296 or 429-496-7296 goes quiet; why_new=pins the narrowness of the new guard;
// seam=none (scan); no_claim=international formats beyond the ones listed
test("real phone numbers still report, including the constant's digits in phone form", () => {
for (const s of [
"tel: +14294967296",
"call 429-496-7296",
"reach +1 429 496 7296",
"phone (429) 496-7296",
"p 429.496.7296",
"call 4294967297 now",
"ring +44 20 7946 0958",
"office 4155550123",
]) {
expect([s, flags("pii.phone.e164", s)]).toEqual([s, true]);
}
});
});
describe("pii.email and package@version filenames", () => {
// Value: protects=a filename with an @ (package@version, or a name ending in a file extension that is not a TLD) raises no email finding;
// fails_when=the guard is removed and terminal-bench@2.0.json reports pii.email again; why_new=no filename guard existed; seam=none (scan);
// no_claim=extensions outside the closed list
test("package@version and @-named files are not email addresses", () => {
for (const s of [
"results in terminal-bench@2.0.json",
"npm pack wrote lodash@4.17.21.tgz",
"config eslint-config@next.yaml",
"cache react-dom@18.2.0.tar.gz",
"pin left-pad@1.3.0.lock",
"notes a@1.2.md",
]) {
expect([s, flags("pii.email", s)]).toEqual([s, false]);
}
expect(looksLikeAtFilename("terminal-bench@2.0.json")).toBe(true);
});
// Value: protects=real addresses still report, including numeric domain labels (163.com, 1.2.com), TLDs that double as file extensions (.sh, .md,
// .py, .zip) and a file named after an address; fails_when=the guard treats any digit label, any extension-like TLD or any trailing extension as a
// filename; why_new=pins the narrowness of the new guard; seam=none (scan); no_claim=every IANA TLD
test("real addresses still report, numeric domains and extension-shaped TLDs included", () => {
for (const s of [
"mail alex@acme-corp.com",
"reach li.wei@163.com",
"ops dev@acme.sh",
"docs ops@acme.md",
"py team@acme.py",
"files sales@acme.zip",
"contact jo@mail.acme.io",
"reach alice@1.2.com",
"export jane.doe@acme-corp.com.json",
"log at ops-team@acme.io.log",
"uploaded jdoe@acme-corp.com.PDF",
"archive li.wei@163.com.tar.gz",
]) {
expect([s, flags("pii.email", s)]).toEqual([s, true]);
}
});
});
+137
View File
@@ -195,6 +195,143 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR
});
});
const MANIFEST = { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json'], answer_markers: ['SEALED ANSWER:'] };
function preflight(s: Scratch, args: string[] = []) {
const r = spawnSync(PREGATE, ['sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
}
function landOnMain(s: Scratch, files: Record<string, string>): void {
sh(s.work, s.env, ['checkout', '-q', 'main']);
write(s.work, files);
sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'base change']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']);
sh(s.work, s.env, ['checkout', '-q', 'results']);
}
describe('sealed.allow_paths: base-branch exceptions applied after the deny globs', () => {
test('a deny with an allowed exception refuses the sealed file, lists the manifest as exempt, and a manifest-only push passes', () => {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { 'sealed/s1/tasks.json': '{}\n', 'sealed/s1/MANIFEST.json': '{"n":1}\n', 'tasks/x/sealed/s2/MANIFEST.json': '{"n":2}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/tasks\.json/m);
expect(r.err).not.toMatch(/^ sealed\.path\s+\S*MANIFEST\.json/m);
expect(r.err).toMatch(/exempt sealed\.path sealed\/s1\/MANIFEST\.json commit [0-9a-f]{12} \(sealed\.allow_paths \*\*\/sealed\/\*\/MANIFEST\.json\)/);
expect(r.err).toContain('exempt sealed.path tasks/x/sealed/s2/MANIFEST.json');
const only = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(only, { 'sealed/s1/MANIFEST.json': '{"n":1}\n' });
expect(hook(only).code).toBe(0);
const pre = preflight(only);
expect(pre.code).toBe(0);
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
const json = JSON.parse(preflight(only, ['--json']).out);
expect(json.exempt).toHaveLength(1);
expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' });
});
test('an exception that does not match still refuses: another case and another depth', () => {
for (const file of ['sealed/s1/manifest.json', 'sealed/s1/x/MANIFEST.json']) {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { [file]: '{}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain(`sealed.path ${file}`);
expect(r.err).not.toContain('exempt sealed.');
}
});
test('an exception added on the pushed head is ignored, on top of a base list too; a malformed pushed allow_paths refuses', () => {
const fresh = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
commit(fresh, { '.gstack/pregate.json': policy(MANIFEST), 'sealed/s1/MANIFEST.json': '{}\n' });
const f = hook(fresh);
expect(f.code).toBe(1);
expect(f.err).toContain('sealed.path sealed/s1/MANIFEST.json');
const extra = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(extra, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: [...MANIFEST.allow_paths, '**/sealed/*/notes.md'] }), 'sealed/s1/notes.md': 'n\n' });
expect(hook(extra).err).toContain('sealed.path sealed/s1/notes.md');
const bad = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(bad, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: '**/sealed/*/MANIFEST.json' }), 'docs/x.md': 'x\n' });
const b = hook(bad);
expect(b.code).toBe(1);
expect(b.err).toContain('sealed.allow_paths');
});
test('an allowed manifest that quotes a sealed answer still refuses on the text rule', () => {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { 'sealed/s1/MANIFEST.json': '{"note":"SEALED ANSWER: b"}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toMatch(/sealed\.answer\s+sealed\/s1\/MANIFEST\.json:1/);
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
});
test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => {
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) });
const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` });
const pre = preflight(s);
expect(pre.code).toBe(0);
expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl');
expect(pre.out).toMatch(/findings=0 .* waived=0 exempt=1 /);
});
test('a preflight error carries the same report shape: exit 1, SEALED: error with exempt=0, no stack trace', () => {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { 'docs/x.md': 'x\n' });
const r = preflight(s, ['--range', 'nosuch..HEAD']);
expect(r.code).toBe(1);
expect(r.out).toMatch(/^SEALED: error findings=0 commits=0 .* waived=0 exempt=0 /m);
expect(r.err).not.toMatch(/\n\s+at /);
expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).exempt).toEqual([]);
});
});
describe('sealed.case_insensitive: deny globs fold case, exceptions never do', () => {
test('off (the default), HELDOUT/ passes a lowercase deny glob; on, HELDOUT/, HeldOut/ and a Transcript refuse, and hold-out/ still passes', () => {
const off = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
commit(off, { 'HELDOUT/q.json': '{}\n' });
expect(hook(off).code).toBe(0);
const on = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], case_insensitive: true }) });
commit(on, { 'HELDOUT/q.json': '{}\n', 'HeldOut/q.json': '{}\n', 'Runs/c1/Transcript.jsonl': '{}\n', 'hold-out/q.json': '{}\n' });
const r = hook(on);
expect(r.code).toBe(1);
expect(r.err).toContain('sealed.path HELDOUT/q.json');
expect(r.err).toContain('sealed.path HeldOut/q.json');
expect(r.err).toContain('sealed.transcript Runs/c1/Transcript.jsonl');
expect(r.err).not.toContain('hold-out/q.json');
});
test('on, allow_paths stay case-sensitive: MANIFEST.json is exempt and manifest.json refuses', () => {
const s = scratch({ '.gstack/pregate.json': policy({ ...MANIFEST, case_insensitive: true }) });
commit(s, { 'sealed/s1/MANIFEST.json': '{}\n', 'sealed/s1/manifest.json': '{}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/manifest\.json/m);
});
test('set only on the pushed head, it still tightens (base OR pushed)', () => {
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
commit(s, { '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], case_insensitive: true }), 'HELDOUT/q.json': '{}\n' });
expect(hook(s).err).toContain('sealed.path HELDOUT/q.json');
});
});
describe('root-anchoring (the docs/pregate.md example)', () => {
test('globs match from the repo root: sealed/** misses a nested sealed/ directory and **/sealed/** catches it at any depth', () => {
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8');
expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`');
expect(docs).toContain('write `**/sealed/**`');
const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) });
commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' });
expect(hook(anchored).code).toBe(0);
const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
commit(any, { 'tasks/x/sealed/a.json': '{}\n', 'sealed/b.json': '{}\n' });
const a = hook(any);
expect(a.err).toContain('sealed.path tasks/x/sealed/a.json');
expect(a.err).toContain('sealed.path sealed/b.json');
});
});
describe('visibility (cache under the state root; unknown counts as public)', () => {
test('a public answer is cached; a cached private answer older than an hour is re-queried before it can downgrade; a failed lookup is unknown', () => {
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-vis-'));
+168
View File
@@ -0,0 +1,168 @@
/**
* gstack-ship-receipt carry for a stacked child after its parent squash-merges (#3570,
* docs/ship-policy.md#receipt-carry): the receipt reviewed the child's own range against the parent
* branch, and that range must be HEAD's whole product diff against the base the PR now targets.
* Anything else (the parent not landed, the child changed) fails closed.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
import { bindShippedReceipt } from '../lib/ship-receipt-depth';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n";
const RETRY4 = "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n";
/** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */
function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/util.js': SUB }, childFiles: Record<string, string> = { 'lib/retry.js': RETRY4 }): { parent: string; child: string; body: string } {
repo.git('checkout', '-q', '-b', 'parent');
const parent = commit(repo, parentFiles, 'parent: sub');
repo.git('push', '-q', 'origin', 'parent');
repo.git('checkout', '-q', 'feature');
repo.git('reset', '-q', '--hard', parent);
const child = commit(repo, childFiles, 'child: four retries');
const body = path.join(repo.root, 'body.md');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'parent', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
expect(w.status).toBe(EXIT.ok);
return { parent, child, body };
}
/** Land `files` on main as one commit (the parent's squash merge; none when the parent never lands), then an unrelated main commit, and merge main into the child. */
function landOnMain(repo: FixtureRepo, files: Record<string, string>): void {
repo.git('checkout', '-q', 'main');
if (Object.keys(files).length) {
for (const [rel, text] of Object.entries(files)) write(repo.work, rel, text);
repo.git('add', '-A'); repo.git('commit', '-qm', 'squash: parent (#7)');
}
write(repo.work, 'docs/README.md', '# docs after the parent\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--no-edit', 'origin/main');
}
describe('carry after a stack parent squash-merges (#3570)', () => {
// Value: protects=a stacked child keeps its receipt when its parent squash-merges and main is merged in (gstack-evals#19
// paid a full /ship for it); fails_when=carry compares the child's whole diff from the old fork point; seam=none;
// no_claim=gh pr edit (--body stands in)
test('the child carries when the range it was reviewed for is HEAD\'s whole product diff against main', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { child, body } = stack(repo);
const reviewed = receiptIn(body).product_patch_id;
landOnMain(repo, { 'lib/util.js': SUB });
const head = repo.git('rev-parse', 'HEAD');
const out = carry(repo, body);
expect(out.status).toBe(EXIT.ok);
expect(out.out).toContain(`CARRY: carried from ${child.slice(0, 12)} to ${head.slice(0, 12)}`);
const r = receiptIn(body);
expect(r.head).toBe(head);
expect(r.carry!.reason).toBe('stack parent parent landed + base merge');
expect(r.product_patch_id).toBe(reviewed);
expect(r.base).toBe('main');
expect(r.review_depth!.base).toBe(repo.git('rev-parse', 'origin/main'));
expect(r.review_depth!.gate_scan.head).toBe(head);
expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '8', [head]).ok).toBe(true);
repo.git('checkout', '-q', 'main');
commit(repo, { 'docs/CHANGES.md': '# main moves again\n' }, 'main moves again');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--no-edit', 'origin/main');
const again = carry(repo, body);
expect(again.status).toBe(EXIT.ok);
expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge + base merge');
expect(receiptIn(body).product_patch_id).toBe(reviewed);
}, 90_000);
// Value: protects=a receipt written before product_patch_id still carries while the parent branch resolves, and refuses by
// name once it is gone; fails_when=the fallback reads another ref or carries with no reviewed diff; seam=none; no_claim=-
test('without a recorded product patch-id the child range comes from origin/<parent>, and a deleted parent branch refuses', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { parent, child, body } = stack(repo);
landOnMain(repo, { 'lib/util.js': SUB });
const { product_patch_id: _dropped, head_tree: _tree, ...legacy } = receiptIn(body);
fs.writeFileSync(body, renderReceiptBlock(legacy as any));
expect(receiptIn(body).product_patch_id).toBeUndefined();
repo.git('push', '-q', 'origin', '--delete', 'parent');
repo.git('fetch', '-q', '--prune', 'origin');
const gone = carry(repo, body);
expect(gone.status).toBe(EXIT.refused);
expect(gone.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)');
expect(gone.err).toContain('the receipt was reviewed against parent, not main, and its reviewed product diff is unavailable');
expect(receiptIn(body).head).toBe(child);
repo.git('push', '-q', 'origin', `${parent}:refs/heads/parent`);
repo.git('fetch', '-q', 'origin');
expect(carry(repo, body).status).toBe(EXIT.ok);
expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge');
}, 90_000);
// Value: protects=a child with no product change of its own (product_patch_id none) still carries once the parent lands;
// fails_when=the recorded 'none' is compared as a literal id; seam=none; no_claim=-
test('a test-only child carries after its parent lands', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { body } = stack(repo, { 'lib/util.js': SUB }, { 'test/retry.test.js': "const test = require('node:test');\ntest('retries', () => {});\n" });
expect(receiptIn(body).product_patch_id).toBe('none');
landOnMain(repo, { 'lib/util.js': SUB });
const out = carry(repo, body);
expect(out.status).toBe(EXIT.ok);
expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge');
}, 90_000);
// Value: protects=fail closed: a child changed after the parent merged, or a parent that never reached main, takes a fresh
// /ship; fails_when=the comparison ignores the child's remainder or the parent's absence; seam=none; no_claim=-
test('a product edit on the child after the merge, or a parent that never landed, refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { child, body } = stack(repo);
landOnMain(repo, { 'lib/util.js': SUB });
commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 5;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }, 'child: five');
const edited = carry(repo, body);
expect(edited.status).toBe(EXIT.refused);
expect(edited.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(body).head).toBe(child);
const other = makeFixtureRepo(); repos.push(other);
const s = stack(other);
landOnMain(other, {});
const abandoned = carry(other, s.body);
expect(abandoned.status).toBe(EXIT.refused);
expect(abandoned.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(abandoned.err).toContain('is not the one reviewed against parent (the parent has not fully landed on main');
expect(receiptIn(s.body).head).toBe(s.child);
}, 120_000);
// Value: protects=fail closed on the parent's release-metadata paths: a package.json change (a dependency and a postinstall
// script) the parent made but main never received is product in HEAD's diff against main, though the child never touched it;
// fails_when=the comparison takes its pathspec from the reviewed head..HEAD range, where package.json is unchanged; seam=none;
// no_claim=-
test('a parent package.json change that never reached main refuses', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const pkg = JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node --test test/*.test.js', generate: 'node gen.js', postinstall: 'node gen.js' }, dependencies: { leftpad: '1.0.0' } }, null, 2) + '\n';
const { child, body } = stack(repo, { 'lib/util.js': SUB, 'package.json': pkg });
landOnMain(repo, { 'lib/util.js': SUB });
const out = carry(repo, body);
expect(out.status).toBe(EXIT.refused);
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(body).head).toBe(child);
}, 90_000);
// Value: protects=the docs row "a parent that landed with changes the child never merged" refuses; fails_when=a merge that
// keeps the child's tree (-s ours) over the landed parent reads as unchanged; seam=none; no_claim=-
test('a parent that landed with changes the child never merged refuses', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { child, body } = stack(repo);
repo.git('checkout', '-q', 'main');
commit(repo, { 'lib/util.js': SUB + "exports.mul = (a, b) => a * b;\n" }, 'squash: parent with a review fix (#7)');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--no-edit', '-s', 'ours', 'origin/main');
const out = carry(repo, body);
expect(out.status).toBe(EXIT.refused);
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(body).head).toBe(child);
}, 90_000);
});