mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* 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>
351 lines
28 KiB
TypeScript
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);
|
|
});
|
|
});
|