Files
gstack/test/gstack-ship-receipt.test.ts
T
Garry Tanandcapy-ai[bot] 6665e20d56 v1.91.236.0 feat(ship-queue): require a current /ship receipt before READY, train build and train merge (#3438)
* feat(ship-queue): require a current /ship receipt before READY, train build and train merge

Garry, 2026-10-25: use the gstack ship skill before merging PRs. ready printed
SHIP_RECEIPT: none as information and returned ok, so the pool was READY'd without
/ship. Now ready, train build (dry run included) and verify --train refuse with
QUEUE_RECEIPT_MISSING or QUEUE_RECEIPT_STALE from the PR body alone; verify accepts
the receipt written for the pinned head when the current head is its stamp commit.

gstack-ship-receipt result is /ship's terminal step (GSTACK_RESULT: skill=ship,
run dir with the head and receipt), unattended /ship may update an existing PR's
body, and INSTALL_UNVERIFIED with no verdict names the project directory to
install from (GSTA-85).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ship): pin Step 22 in workflow-required-steps and tighten the ship skeleton

Gate edit: test/fixtures/workflow-required-steps.json pins the new
"## Step 22: Terminal line (every run)" heading (strengthens: a later
edit that drops the terminal line turns the required-steps test red).
Steps 14, 16, 18, 20 and 22 prose tightened so the carve-guards raise
shrinks to 81,600 / 1.6740 (measured skeleton 81,535, union 314,027).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (ready at the stamp commit, screen on a stale merge ref, ship-run.json)

- ready accepts the pin's receipt at a slot's stamp commit (plus the slot-2
  merge of main), as verify --train already did; a real edit stays STALE
- ready --screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge is
  missing or merges an older head; a finished screen that planned 0 cases is
  never followed, and train build seats that slot unscreened (GSTA-95 on #3431)
- gstack-ship-receipt result writes ship-run.json (a /ship record, not a run
  v1 manifest), validates --head, and prints status=incomplete on gh failures
- bindReceipt takes an optional pr and renders one-line SHIP_RECEIPT lines
- tests for every state-matrix cell the review named; the train fixture's
  merge ref is a real merge commit

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: cycle-2 review fixes (private screen scratch, no bare-merge receipt reuse, 12-hex --head, one receipt scrub) and align the unattended hook text with the GSTACK_RESULT writers (GSTA-91)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: adversarial-review fixes (result refuses an unpushed HEAD, resolves --out and reports unwritable records; ready names a failed merge-ref fetch; unattended /ship never runs gh pr ready)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: close review-cycle gaps (a stamp must change a stamp file, result requires a full --head and fails closed without headRefOid, digits-only --pr) with tests for every cycle-3 branch

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: give the fsck-rejected merge-ref spawns a timeout (spawnsync tripwire)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: detect a hollow screen from the plan it uploads (no paid-plan artifact or nothing planned), correct two troubleshooting facts, and normalise result --pr

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hollow-screen): read manifest.json by name, list artifacts by name, say when the plan is unreadable; lowercase result --head

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(hollow-screen): expired and unreadable run rows, uppercase --head; docs name every SCREEN: literal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(train build): say when the repository slug is unreadable instead of counting the screen silently

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: name an ls-remote failure in ready --screen, refuse result --pr with --body, cap echoed receipt errors, tell an unreadable install verdict from a missing one

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: ls-remote refusal, --pr with --body, capped receipt errors, unreadable slug and verdict (both twins); docs name the new literals

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: scrub Unicode line separators from receipt text, report an unreadable --body as incomplete, refuse unsupported verdict schemas in the bash twin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 09:03:59 +00:00

351 lines
28 KiB
TypeScript

/**
* 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, computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } 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`);
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']);
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!.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('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);
});
});