mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
* feat(ship): pause /ship on a red base instead of failing (gstack-base-red, conditional receipts) (GSTA-157 PR 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: base-red shell runner launches bash through runBashSync Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: base-red coverage additions (unreproduced head, unresolved head, readPause, wait unconfigured) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: base-red pause in unattended endings; BASE_RED_UNCONFIGURED covers an invalid base policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: base-red coverage pass 2 (write --base-red, summary line, classify usage before fetch, unresolved-head detail) 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>
285 lines
21 KiB
TypeScript
285 lines
21 KiB
TypeScript
/**
|
|
* gstack-base-red (docs/ship-policy.md#base-red): a Step 5 failure is base-red
|
|
* only when the base branch's policy declares how to reproduce it, every
|
|
* failure is named, the PR touches none of the failing files or the paths
|
|
* their signatures name, and the same test fails the same way at the base
|
|
* head and at the PR head in scratch worktrees. A conditional receipt is
|
|
* refused by every receipt consumer except /ship's own terminal line, which
|
|
* reports SHIP_PAUSED; wait returns only once a new base head passes.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { classifyBaseRed, parseEvidence, pausePath, readPause, receiptBaseRed, shellRunner, waitBaseGreen, writePause, type FailureEvidence, type Runner } from '../lib/base-red';
|
|
import { EXIT } from '../lib/headless-artifacts';
|
|
import { bindReceipt, extractReceipt, placeReceiptFirst, receiptSummaryLine } from '../lib/ship-receipt';
|
|
import { BASE_RED_KEY_VERSION, validatePolicy, type BaseRedPolicy } from '../lib/ship-policy';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const ID = { GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid' };
|
|
const SELECT = 'for f in {files}; do bash "$f" || status=1; done; exit ${status:-0}';
|
|
const POLICY: BaseRedPolicy = { setup: null, select: SELECT, timeout_sec: 60 };
|
|
const FAIL_TEST = 'echo "(fail) totals add up"; echo "error: expected 3 got 4 in lib/sum.sh"; exit 1\n';
|
|
const PASS_TEST = 'echo "(pass) totals add up"\n';
|
|
|
|
let root = '';
|
|
let work = '';
|
|
let state = '';
|
|
const prevState = process.env.GSTACK_STATE_ROOT;
|
|
const git = (...args: string[]) => {
|
|
const r = spawnSync('git', args, { cwd: work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...ID } });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
};
|
|
const write = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(work, rel)), { recursive: true }); fs.writeFileSync(path.join(work, rel), text); };
|
|
const policyJson = (base_red: BaseRedPolicy | null) => JSON.stringify({ base_red, min_gstack_version: base_red ? BASE_RED_KEY_VERSION : null }) + '\n';
|
|
const evidence = (over: Partial<FailureEvidence> = {}): FailureEvidence => ({ lane: 'tests', total: 1, failures: [{ id: 'totals add up', file: 't/sum.test.sh', signature: 'error: expected 3 got 4' }], ...over });
|
|
const counting = () => { const calls: string[] = []; const runner: Runner = (cwd, cmd, s) => { calls.push(cmd); return shellRunner(cwd, cmd, s); }; return { calls, runner }; };
|
|
|
|
/** origin/main carries a red test; the PR branch changes only README.md. */
|
|
function fixture(base: { test?: string; policy?: BaseRedPolicy | null } = {}) {
|
|
write('t/sum.test.sh', base.test ?? FAIL_TEST);
|
|
write('lib/sum.sh', 'echo 4\n');
|
|
write('README.md', 'base\n');
|
|
write('.gstack/ship-policy.json', policyJson(base.policy === undefined ? POLICY : base.policy));
|
|
git('add', '-A'); git('commit', '-qm', 'base'); git('push', '-q', 'origin', 'HEAD:main'); git('fetch', '-q', 'origin');
|
|
git('checkout', '-q', '-b', 'feat');
|
|
write('README.md', 'pr\n'); git('commit', '-qam', 'pr: readme');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'base-red-'));
|
|
work = path.join(root, 'work');
|
|
state = path.join(root, 'state');
|
|
process.env.GSTACK_STATE_ROOT = state;
|
|
spawnSync('git', ['init', '-q', '--bare', '-b', 'main', path.join(root, 'origin.git')], { timeout: 30_000 });
|
|
fs.mkdirSync(work);
|
|
git('init', '-q', '-b', 'main'); git('remote', 'add', 'origin', path.join(root, 'origin.git'));
|
|
});
|
|
afterEach(() => {
|
|
if (prevState === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prevState;
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('classify', () => {
|
|
test('a failure that reproduces at base and head in a file the PR does not touch is base-red, and leaves no worktree behind', () => {
|
|
fixture();
|
|
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: POLICY });
|
|
expect(r.code).toBe('BASE_RED');
|
|
expect(r.cases).toEqual([{ id: 'totals add up', file: 't/sum.test.sh', verdict: 'BASE_RED', reason: `fails the same way at base ${r.base_sha.slice(0, 12)}` }]);
|
|
expect(git('worktree', 'list').split('\n')).toHaveLength(1);
|
|
expect(fs.readdirSync(path.join(state, 'ship', 'base-red', 'worktrees'))).toEqual([]);
|
|
});
|
|
|
|
test('the PR touching the failing test, or a path its signature names, is in-branch without running anything', () => {
|
|
fixture();
|
|
write('t/sum.test.sh', FAIL_TEST + '# touched\n'); git('commit', '-qam', 'touch test');
|
|
const a = counting();
|
|
const touched = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: POLICY, runner: a.runner });
|
|
expect([touched.code, touched.cases[0]!.reason, a.calls]).toEqual(['IN_BRANCH', 'the PR changes t/sum.test.sh', []]);
|
|
git('reset', '-q', '--hard', 'HEAD~1');
|
|
write('lib/sum.sh', 'echo 5\n'); git('commit', '-qam', 'touch lib');
|
|
const named = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ failures: [{ id: 'totals add up', file: 't/sum.test.sh', signature: 'error: expected 3 got 4 in lib/sum.sh' }] }), policy: POLICY, runner: a.runner });
|
|
expect([named.code, named.cases[0]!.reason, a.calls]).toEqual(['IN_BRANCH', 'the PR changes lib/sum.sh', []]);
|
|
});
|
|
|
|
test('a test that passes on the base, or fails there with another signature, is in-branch', () => {
|
|
fixture({ test: PASS_TEST });
|
|
const passes = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: POLICY });
|
|
expect(passes.code).toBe('IN_BRANCH');
|
|
expect(passes.cases[0]!.reason).toStartWith('passes at base ');
|
|
const other = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ failures: [{ id: 'totals add up', file: 't/sum.test.sh', signature: 'error: timeout after 5000ms' }] }), policy: { ...POLICY, select: 'bash -c "echo totals add up; echo error: expected; exit 1" # {files}' } });
|
|
expect([other.code, other.cases[0]!.reason.split(' at base')[0]]).toEqual(['IN_BRANCH', 'fails with a different signature']);
|
|
});
|
|
|
|
test('no base_red policy, an unnamed failure, or a failing setup keeps every failure in-branch', () => {
|
|
fixture({ policy: null });
|
|
expect(classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: null }).code).toBe('BASE_RED_UNCONFIGURED');
|
|
expect(classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ total: 2 }), policy: POLICY }).code).toBe('BASE_RED_UNNAMED');
|
|
const setup = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: { ...POLICY, setup: 'exit 7' } });
|
|
expect([setup.code, setup.detail, setup.cases.every(c => c.verdict === 'IN_BRANCH')]).toEqual(['BASE_RED_REPRO_ERROR', 'setup exited 7', true]);
|
|
});
|
|
|
|
test('evidence must name the lane, the total and repo-relative files', () => {
|
|
expect(parseEvidence({ lane: 'tests', total: 0, failures: [] })).toContain('total');
|
|
expect(parseEvidence({ lane: 'tests', total: 1, failures: [{ id: 'x', file: '../escape.sh', signature: 's' }] })).toContain('repo-relative');
|
|
expect(typeof parseEvidence(evidence())).toBe('object');
|
|
});
|
|
});
|
|
|
|
describe('pause, receipt and wait', () => {
|
|
test('the pause path hashes the branch; the receipt block from it is refused by default and bound only on request', () => {
|
|
fixture();
|
|
const ev = evidence();
|
|
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: ev, policy: POLICY });
|
|
const { file } = writePause(work, 'feat/../../etc', r, ev, new Date('2026-10-29T00:00:00Z'));
|
|
expect(file).toBe(pausePath(work, 'feat/../../etc'));
|
|
expect(path.basename(file)).toMatch(/^[0-9a-f]{16}\.json$/);
|
|
const pause = readPause(file);
|
|
if (typeof pause === 'string') throw new Error(pause);
|
|
expect(pause.expires_at).toBe('2026-10-30T00:00:00.000Z');
|
|
const head = git('rev-parse', 'HEAD');
|
|
const body = placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any);
|
|
expect(extractReceipt(body).errors).toEqual([]);
|
|
const refused = bindReceipt(body, '7', [head]);
|
|
expect(refused.ok ? 'bound' : refused.code).toBe('QUEUE_RECEIPT_CONDITIONAL');
|
|
expect(bindReceipt(body, '7', [head], { conditional: true }).ok).toBe(true);
|
|
});
|
|
|
|
test('the terminal line reports SHIP_PAUSED with status=incomplete, and carry refuses a conditional receipt', () => {
|
|
fixture();
|
|
const ev = evidence();
|
|
fs.writeFileSync(path.join(root, 'ev.json'), JSON.stringify(ev));
|
|
const bin = (name: string, args: string[]) => spawnSync('bun', [path.join(ROOT, 'bin', name), ...args], { cwd: work, encoding: 'utf8', timeout: 120_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state } });
|
|
const c = bin('gstack-base-red', ['classify', '--failures', path.join(root, 'ev.json'), '--base', 'main', '--pause']);
|
|
expect(c.status).toBe(EXIT.ok);
|
|
const pauseLine = c.stdout.split('\n').find(l => l.startsWith('PAUSE: '))!;
|
|
expect(c.stdout).toContain('BASE_RED_VERDICT: base-red lane=tests');
|
|
const pause = readPause(pauseLine.slice(7));
|
|
if (typeof pause === 'string') throw new Error(pause);
|
|
const head = git('rev-parse', 'HEAD');
|
|
const bodyFile = path.join(root, 'body.md');
|
|
fs.writeFileSync(bodyFile, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any));
|
|
const res = bin('gstack-ship-receipt', ['result', '--body', bodyFile, '--head', head, '--out', path.join(root, 'run')]);
|
|
expect(res.status).toBe(EXIT.fail);
|
|
expect(res.stdout.trim()).toBe(`GSTACK_RESULT: skill=ship status=incomplete run=${path.join(root, 'run')}`);
|
|
expect(res.stderr).toContain(`SHIP_PAUSED: base-red cases=1 base=${pause.base_sha.slice(0, 12)} lane=tests`);
|
|
expect(JSON.parse(fs.readFileSync(path.join(root, 'run', 'ship-run.json'), 'utf8')).status).toBe('incomplete');
|
|
const carry = bin('gstack-ship-receipt', ['carry', '--body', bodyFile]);
|
|
expect([carry.status, carry.stdout.split('\n')[0]]).toEqual([EXIT.refused, 'CARRY: refused (QUEUE_RECEIPT_CONDITIONAL)']);
|
|
});
|
|
|
|
test('wait skips a base move that still fails and returns on the first head that passes', () => {
|
|
fixture();
|
|
const ev = evidence();
|
|
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: ev, policy: POLICY });
|
|
const pause = writePause(work, 'feat', r, ev).record;
|
|
git('checkout', '-q', 'main');
|
|
write('README.md', 'still red\n'); git('commit', '-qam', 'unrelated'); const stillRed = git('rev-parse', 'HEAD');
|
|
write('t/sum.test.sh', PASS_TEST); git('commit', '-qam', 'fix'); const green = git('rev-parse', 'HEAD');
|
|
git('push', '-q', 'origin', 'main');
|
|
const heads = [pause.base_sha, stillRed, stillRed, green];
|
|
let t = 0;
|
|
const res = waitBaseGreen({ repoRoot: work, pause, policy: POLICY, timeoutSec: 600, pollSec: 5, lsRemote: () => heads.shift() ?? green, now: () => t, sleep: ms => { t += ms; } });
|
|
expect(res).toEqual({ code: 'BASE_GREEN', sha: green, checked: 2 });
|
|
const expired = waitBaseGreen({ repoRoot: work, pause: { ...pause, expires_at: '2000-01-01T00:00:00Z' }, policy: POLICY, timeoutSec: 60, pollSec: 5, lsRemote: () => green });
|
|
expect(expired.code).toBe('BASE_RED_PAUSE_EXPIRED');
|
|
let u = 0;
|
|
const timeout = waitBaseGreen({ repoRoot: work, pause, policy: POLICY, timeoutSec: 30, pollSec: 5, lsRemote: () => pause.base_sha, now: () => u, sleep: ms => { u += ms; } });
|
|
expect([timeout.code, timeout.checked]).toEqual(['BASE_RED_WAIT_TIMEOUT', 0]);
|
|
});
|
|
});
|
|
|
|
describe('policy', () => {
|
|
test('base_red needs {files} in select, bounded timeout, no unknown keys and the introducing min_gstack_version', () => {
|
|
const codes = (base_red: unknown, min: string | null = BASE_RED_KEY_VERSION) => validatePolicy({ base_red, min_gstack_version: min }).map(e => `${e.code}@${e.path}`);
|
|
expect(codes(POLICY)).toEqual([]);
|
|
expect(codes({ ...POLICY, setup: 'bun install --frozen-lockfile' })).toEqual([]);
|
|
expect(codes({ ...POLICY, select: 'bun test' })).toEqual(['POLICY_INVALID@base_red.select']);
|
|
expect(codes({ ...POLICY, timeout_sec: 5 })).toEqual(['POLICY_INVALID@base_red.timeout_sec']);
|
|
expect(codes({ ...POLICY, extra: 1 })).toEqual(['POLICY_INVALID@base_red.extra']);
|
|
expect(codes({ ...POLICY, select: 'a {files}\nrm -rf /' })).toEqual(['POLICY_CONTAINMENT@base_red.select']);
|
|
expect(codes(POLICY, null)).toEqual(['POLICY_INVALID@base_red']);
|
|
});
|
|
});
|
|
|
|
describe('coverage audit additions', () => {
|
|
// Generated by /ship Step 7 coverage audit (PR #3611).
|
|
// Value: protects=a failure reproduced at base but not by the restricted run at the PR head stays in-branch, and one unreproduced case keeps the whole verdict in-branch;
|
|
// fails_when=the head reproduction or the every-case aggregation is dropped, so a PR that changes behaviour is paused as base-red; why_new=existing tests vary only the base run, with one case;
|
|
// seam=none; no_claim=real test runners (shell scripts stand in for the lane's tests)
|
|
test('a case the head run does not reproduce, or a case the base output never names, keeps the verdict in-branch', () => {
|
|
fixture();
|
|
write('pr-only.txt', 'x\n'); git('add', '-A'); git('commit', '-qm', 'pr: marker');
|
|
const headPasses = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: { ...POLICY, select: `[ -f pr-only.txt ] && { echo "(pass) totals add up"; exit 0; }; ${SELECT}` } });
|
|
expect([headPasses.code, headPasses.cases[0]!.reason]).toEqual(['IN_BRANCH', 'the restricted command passes at head']);
|
|
const ghost = { id: 'ghost case', file: 't/sum.test.sh', signature: 'error: expected 3 got 4' };
|
|
const mixed = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ total: 2, failures: [evidence().failures[0]!, ghost] }), policy: POLICY });
|
|
expect(mixed.code).toBe('IN_BRANCH');
|
|
expect(mixed.cases.map(c => c.verdict)).toEqual(['BASE_RED', 'IN_BRANCH']);
|
|
expect(mixed.cases[1]!.reason).toStartWith('fails without naming this test at base ');
|
|
});
|
|
|
|
// Generated by /ship Step 7 coverage audit (PR #3611).
|
|
// Value: protects=an unresolvable PR head is BASE_RED_REPRO_ERROR with every case in-branch and nothing run; fails_when=the commit-resolution guard is removed or inverted;
|
|
// why_new=no existing test resolves a missing commit; seam=none; no_claim=none beyond protects
|
|
test('an unresolvable head is a reproduction error that runs nothing', () => {
|
|
fixture();
|
|
const a = counting();
|
|
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'no-such-ref', evidence: evidence(), policy: POLICY, runner: a.runner });
|
|
expect([r.code, r.cases.every(c => c.verdict === 'IN_BRANCH'), a.calls]).toEqual(['BASE_RED_REPRO_ERROR', true, []]);
|
|
expect([r.detail, r.cases[0]!.reason]).toEqual([`cannot resolve origin/main or no-such-ref`, 'unresolved commit']);
|
|
});
|
|
|
|
// Generated by /ship Step 7 coverage audit (PR #3611).
|
|
// Value: protects=readPause rejects missing or non-pause files, and wait refuses BASE_RED_UNCONFIGURED once the base no longer declares base_red;
|
|
// fails_when=readPause accepts any JSON or wait polls without a usable base policy; why_new=existing tests read only valid pauses and never run the wait CLI;
|
|
// seam=none; no_claim=remote fetch over a network (local bare origin)
|
|
test('readPause rejects non-pause files and the wait CLI refuses when the base declares no base_red', () => {
|
|
fixture({ policy: null });
|
|
const bogus = path.join(root, 'bogus.json');
|
|
fs.writeFileSync(bogus, JSON.stringify({ kind: 'other', schema_version: 1, cases: [] }));
|
|
expect(readPause(bogus)).toContain('is not a base-red pause record');
|
|
expect(readPause(path.join(root, 'missing.json'))).toStartWith('cannot read ');
|
|
const pauseFile = path.join(root, 'pause.json');
|
|
fs.writeFileSync(pauseFile, JSON.stringify({ schema_version: 1, kind: 'base-red-pause', branch: 'feat', head: git('rev-parse', 'HEAD'), base: 'main', base_sha: git('rev-parse', 'origin/main'), lane: 'tests', cases: evidence().failures, created_at: '2026-10-29T00:00:00Z', expires_at: '2999-01-01T00:00:00Z' }));
|
|
const w = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-base-red'), 'wait', '--pause', pauseFile, '--timeout', '30', '--poll', '5'], { cwd: work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state } });
|
|
expect(w.status).toBe(EXIT.refused);
|
|
expect(w.stderr).toContain('BASE_RED_UNCONFIGURED');
|
|
});
|
|
|
|
// Generated by /ship Step 7 coverage audit (PR #3611), pass 2.
|
|
// Value: protects=write --base-red adds the base_red block from a valid pause, and an unreadable or non-pause file exits usage with nothing written;
|
|
// fails_when=the flag is ignored, so an unconditional receipt is written for a paused run; why_new=no test runs write with --base-red;
|
|
// seam=none; no_claim=PR body upload via gh (local body file only)
|
|
test('write --base-red makes the receipt conditional, and a bad pause file writes nothing', () => {
|
|
fixture();
|
|
const ev = evidence();
|
|
const { file, record } = writePause(work, 'feat', classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: ev, policy: POLICY }), ev);
|
|
const body = path.join(root, 'body.md');
|
|
const run = (pause: string) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-ship-receipt'), 'write', '--pr', '7', '--base', 'main', '--session-kind', 'x', '--body', body, '--base-red', pause], { cwd: work, encoding: 'utf8', timeout: 120_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state } });
|
|
for (const bad of [path.join(root, 'missing.json'), path.join(root, 'ev.json')]) {
|
|
fs.writeFileSync(path.join(root, 'ev.json'), JSON.stringify(ev));
|
|
fs.writeFileSync(body, 'prose\n');
|
|
const r = run(bad);
|
|
expect([r.status, r.stderr.includes('--base-red: '), r.stderr.includes('nothing written'), fs.readFileSync(body, 'utf8')]).toEqual([EXIT.usage, true, true, 'prose\n']);
|
|
}
|
|
const ok = run(file);
|
|
expect(ok.status).toBe(EXIT.ok);
|
|
const head = git('rev-parse', 'HEAD');
|
|
const written = fs.readFileSync(body, 'utf8');
|
|
expect(extractReceipt(written).receipt?.base_red).toEqual(receiptBaseRed(record));
|
|
const bound = bindReceipt(written, '7', [head]);
|
|
expect(bound.ok ? 'bound' : bound.code).toBe('QUEUE_RECEIPT_CONDITIONAL');
|
|
});
|
|
|
|
// Generated by /ship Step 7 coverage audit (PR #3611), pass 2.
|
|
// Value: protects=the SHIP_RECEIPT summary line names base_red=<base12> for a conditional receipt and omits it otherwise; fails_when=the base_red part is dropped or printed unconditionally;
|
|
// why_new=summary-line tests cover carried_from only; seam=none; no_claim=none beyond protects
|
|
test('the summary line names the red base only for a conditional receipt', () => {
|
|
const base = 'c'.repeat(40);
|
|
const plain = { schema_version: 1, head: 'a'.repeat(40), base: 'main' } as any;
|
|
expect(receiptSummaryLine({ ...plain, base_red: { cases: ['x'], files: ['t/x.sh'], base, lane: 'tests', expires_at: '2026-10-30T00:00:00Z' } })).toContain(` base_red=${base.slice(0, 12)}`);
|
|
expect(receiptSummaryLine(plain)).not.toContain('base_red=');
|
|
});
|
|
|
|
// Generated by /ship Step 7 coverage audit (PR #3611), pass 2.
|
|
// Value: protects=classify exits usage on a missing --failures, an unreadable file or invalid evidence before any git fetch; fails_when=evidence checks move after the policy fetch or stop refusing;
|
|
// why_new=parseEvidence is unit-tested only, never through the CLI; seam=none; no_claim=none beyond protects (GIT_TRACE proves no fetch ran)
|
|
test('classify refuses bad evidence with a usage exit before fetching anything', () => {
|
|
git('commit', '-q', '--allow-empty', '-m', 'init');
|
|
const trace = path.join(root, 'git-trace.log');
|
|
const bad = path.join(root, 'bad.json');
|
|
fs.writeFileSync(bad, JSON.stringify({ lane: 'tests', total: 1, failures: [{ id: 'x', file: '/abs.sh', signature: 's' }] }));
|
|
const cases: Array<[string[], string]> = [[[], '--failures <evidence.json> is required'], [['--failures', path.join(root, 'missing.json')], 'cannot read '], [['--failures', bad], 'must be repo-relative']];
|
|
for (const [args, why] of cases) {
|
|
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-base-red'), 'classify', ...args], { cwd: work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state, GIT_TRACE: trace } });
|
|
expect([r.status, r.stderr.includes(why)]).toEqual([EXIT.usage, true]);
|
|
}
|
|
const traced = fs.readFileSync(trace, 'utf8');
|
|
expect([traced.includes('rev-parse'), traced.includes('fetch')]).toEqual([true, false]);
|
|
});
|
|
});
|