mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
Merge commit '191a44c84c5eb0608e6db893d8b7bd6fb29a347f' into gsta-96-dedupe
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
39c94164fc
100 files changed
+5431
-309
No files matched your search
@@ -3,7 +3,8 @@
|
||||
* parent agent reads to drive /autoplan through `gstack-autoplan`. It is
|
||||
* generated from autoplan/DRIVER.md.tmpl by resolvers that read the code
|
||||
* (phase order, scope flags, run files, RESULT FORMAT, result codes, the
|
||||
* coordinator contract), stays under 160 lines with no harness vocabulary,
|
||||
* coordinator contract), stays at or under 162 lines (160 until the decision
|
||||
* trail paragraph joined the coordinator contract section) with no harness vocabulary,
|
||||
* is in the generator's normal, out-dir and dry-run inventories, and is
|
||||
* found from an installed root through its recorded `.source-path`.
|
||||
*/
|
||||
@@ -47,8 +48,8 @@ describe('autoplan/DRIVER.md content comes from the code', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('at most 160 lines and no harness vocabulary', () => {
|
||||
expect(doc.split('\n').length).toBeLessThanOrEqual(160);
|
||||
test('at most 162 lines and no harness vocabulary', () => {
|
||||
expect(doc.split('\n').length).toBeLessThanOrEqual(162);
|
||||
for (const term of ['PreToolUse', 'PostToolUse', 'SessionStart', 'AskUserQuestion', 'Skill tool']) expect(doc).not.toContain(term);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Plan E16/E22 (lane F, autoplan gates):
|
||||
* E16 every gate prints its cost: `GATE_SPEND:` from the run's ledger and
|
||||
* `GATE_REC_COST:` for the phases a `rec` reopens (an upper bound priced on
|
||||
* the effective choices, as `answer` reads them); the owner brief carries
|
||||
* the same sentence. Host-subagent attempts are priced unknown, never zero.
|
||||
* E22 the approvals mirror honours the directory a Captain named by creating
|
||||
* `approvals/` in its drive, between GSTACK_APPROVALS_DIR and the private
|
||||
* project-user folder; `gstack-approvals path` names the rule.
|
||||
*/
|
||||
import { afterAll, 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 { buildRow, captainApprovalsDir, locate, readRecord, type ApprovalRow } from '../lib/approval-record';
|
||||
import { gateCost, priorChoices, recReopens, renderGateCost, gateCostSentence } from '../lib/autoplan-gate-cost';
|
||||
import type { GateList } from '../lib/gate-list';
|
||||
import { attemptsOf, autoplan, env, readRun, resultOf, review, ROOT, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-gate-cost-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
const keep = { key: 'a', text: 'keep your direction', changes_plan: false };
|
||||
const change = { key: 'b', text: 'accept the change', changes_plan: true };
|
||||
const list = (recommended: 'a' | 'b'): GateList => ({
|
||||
schema_version: 1, run: 'r', gate_rev: 1, items: [
|
||||
{ id: 'uc1', title: 'challenge', options: [keep, change], recommended, kind: 'approval', phase: 'ceo' },
|
||||
{ id: 'd1', title: 'auto', options: [keep, change], recommended: 'a', kind: 'auto', phase: 'design' },
|
||||
{ id: 'p1', title: 'Plan approval', options: [{ key: 'a', text: 'approve as-is' }], recommended: 'a', kind: 'approval' },
|
||||
],
|
||||
});
|
||||
const row = (over: Partial<Parameters<typeof buildRow>[0]> = {}, branch = 'main'): ApprovalRow => {
|
||||
const r = buildRow({ ask: 'autoplan gates', words: 'assume Garry will take the recommendations', by: 'Garry (user-scope AGENTS.md)', scope: 'autoplan gates: take the recommendations', outcome: 'standing', date: '2026-10-20', source: 'AGENTS.md', ...over }, branch);
|
||||
if ('code' in r) throw new Error(r.message);
|
||||
return r;
|
||||
};
|
||||
|
||||
describe('E16: the gate prints what it cost and what rec adds', () => {
|
||||
test('recReopens is the upper bound of the answer and revise rules: a plan change counts every phase revise can keep or add, a recommended round grant its phase plus Eng, nothing otherwise', () => {
|
||||
const pinned = { ui: false, dx: false };
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual(['ceo', 'design', 'eng']);
|
||||
expect(recReopens(list('a'), { phase_order: ['ceo', 'design', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
// revise re-detects scope: a run that skipped Design and DX without pinning them off can gain both.
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'] })).toEqual(['ceo', 'design', 'dx', 'eng']);
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: { ui: false } })).toEqual(['ceo', 'dx', 'eng']);
|
||||
// an answer already recorded at this gate wins over the recommendation, as rec itself keeps it
|
||||
expect(recReopens(list('a'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
const carried = list('b'); carried.items[0]!.carried = { from: 'x', chosen: 'b', gate_rev: 1 };
|
||||
expect(recReopens(carried, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
// a carried choice overridden at this gate is a change again; an override of a non-changing recommendation counts as answerGate counts it
|
||||
const carriedA = list('a'); carriedA.items[0]!.carried = { from: 'x', chosen: 'a', gate_rev: 1 };
|
||||
expect(recReopens(carriedA, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
const plain = list('a'); plain.items[0]!.options = [{ key: 'a', text: 'keep' }, { key: 'b', text: 'other' }];
|
||||
expect(recReopens(plain, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { uc1: 'b' })).toEqual(['ceo', 'eng']);
|
||||
// the plan approval: a recorded rerun reopens every phase; a reject or stop reopens nothing
|
||||
const rerun = list('a'); rerun.items[2]!.options.push({ key: 'd', text: 'rerun every phase' }, { key: 'e', text: 'reject' });
|
||||
expect(recReopens(rerun, { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'd' })).toEqual(['ceo', 'eng']);
|
||||
expect(recReopens(list('b'), { phase_order: ['ceo', 'eng'], scope_flags: pinned }, { p1: 'e' })).toEqual([]);
|
||||
const agent = list('b'); agent.items[0]!.reopens = false;
|
||||
expect(recReopens(agent, { phase_order: ['ceo', 'eng'], scope_flags: pinned })).toEqual([]);
|
||||
const grant = list('a');
|
||||
grant.items.splice(1, 0, { id: 'c1', title: 'design at its round cap', options: [{ key: 'a', text: 'accept as deferred', changes_plan: false }, { key: 'b', text: 'one more round', changes_plan: false }], recommended: 'b', kind: 'approval', phase: 'design', cap: { phase: 'design', kind: 'deferred' } });
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual(['design', 'eng']);
|
||||
grant.items[1]!.recommended = 'a';
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] })).toEqual([]);
|
||||
expect(recReopens(grant, { phase_order: ['ceo', 'design', 'dx', 'eng'] }, { c1: 'b' })).toEqual(['design', 'eng']);
|
||||
});
|
||||
|
||||
test('priorChoices reads the answers already recorded at this gate revision, as answerGate does; gateCost prices the partial answer, not the recommendation', () => {
|
||||
const out = path.join(TMP, 'prior'); fs.mkdirSync(out, { recursive: true });
|
||||
const rows = [
|
||||
{ schema_version: 1, run: 'r', id: 'r-UC1', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 1, chosen: 'b' },
|
||||
{ schema_version: 1, run: 'r', id: 'r-D1', label: 'D1', kind: 'auto', status: 'pending', gate_rev: 1, chosen: null },
|
||||
{ schema_version: 1, run: 'r', id: 'r-OLD', label: 'UC1', kind: 'approval', status: 'approved', gate_rev: 0, chosen: 'a' },
|
||||
];
|
||||
fs.writeFileSync(path.join(out, 'decisions.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
expect(priorChoices(out, 1)).toEqual({ uc1: 'b' });
|
||||
expect(priorChoices(path.join(TMP, 'no-decisions'), 1)).toEqual({});
|
||||
const c = gateCost(out, list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null, scope_flags: { ui: false, dx: false } }, { defaultPerVoiceUsd: 2 });
|
||||
expect(c).toMatchObject({ reopens: ['ceo', 'eng'], voices: 2, rec_more_usd: 4 });
|
||||
});
|
||||
|
||||
test('the lines price the reopen at the per-voice reservation, two voices each with the outside voice on, and name unknown host attempts', () => {
|
||||
const out = path.join(TMP, 'cost'); fs.mkdirSync(out, { recursive: true });
|
||||
fs.writeFileSync(path.join(out, 'spend.json'), JSON.stringify({ schema_version: 1, cap_usd: 40, spent_usd: 1.5, reserved: [], released: [],
|
||||
settled: [{ attempt: 'a1', usd: 1.5, reserved_usd: 5, at: 't' }, { attempt: 'a2', usd: 'unknown', reserved_usd: 5, at: 't' }] }));
|
||||
const c = gateCost(out, list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 40, scope_flags: { dx: false } });
|
||||
expect(c).toMatchObject({ attempts: 2, reopens: ['ceo', 'design', 'eng'], voices: 6, per_voice_usd: 5, rec_more_usd: 30 });
|
||||
const lines = renderGateCost(c);
|
||||
expect(lines[0]).toBe('GATE_SPEND: spent=1.50 worst_case=6.50 cap=40.00 headroom=33.50 attempts=2; 1 host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host\'s cost view)');
|
||||
expect(lines[1]).toBe('GATE_REC_COST: rec reopens up to ceo,design,eng ≈ 6 voice(s) × 5.00 = +30.00 at most (the run\'s per-voice reservation) → headroom 3.50');
|
||||
expect(gateCostSentence(c)).toContain('Taking every recommendation reopens up to ceo, design, eng: at most +30.00 more.');
|
||||
const variant = (name: string, cap: string) => { const d = path.join(TMP, name); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'spend.json'), fs.readFileSync(path.join(out, 'spend.json'), 'utf8').replace('"cap_usd":40', `"cap_usd":${cap}`)); return d; };
|
||||
const over = renderGateCost(gateCost(variant('cost-tight', '10'), list('b'), { phase_order: ['ceo', 'design', 'eng'], outside_enabled: true, spend_cap_usd: 10, scope_flags: { dx: false } }));
|
||||
expect(over[1]).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
|
||||
const uncapped = gateCost(variant('cost-uncapped', 'null'), list('a'), { phase_order: ['ceo', 'eng'], outside_enabled: false, spend_cap_usd: null });
|
||||
const none = renderGateCost(uncapped);
|
||||
expect(none[0]).toContain('cap=none headroom=none');
|
||||
expect(none[1]).toBe('GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00');
|
||||
expect(gateCostSentence(uncapped)).toBe('Cost so far: spent 1.50, worst case 6.50 (no cap set, 1 host attempt(s) unknown and counted at reservation; 2 reviewer attempt(s)). Taking every recommendation reopens nothing: no further spend.');
|
||||
const empty = gateCost(path.join(TMP, 'no-ledger'), list('b'), { phase_order: ['ceo', 'eng'], outside_enabled: true, spend_cap_usd: null }, { defaultPerVoiceUsd: 5 });
|
||||
expect(empty).toMatchObject({ attempts: 0, per_voice_usd: 5, rec_more_usd: 40, reopens: ['ceo', 'design', 'dx', 'eng'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('E22: the mirror honours the Captain-named approvals directory', () => {
|
||||
const home = path.join(TMP, 'home');
|
||||
const drive = path.join(home, '.capy', 'drive');
|
||||
fs.mkdirSync(path.join(drive, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
|
||||
const base = { GSTACK_CAPY: '1', HOME: home, GSTACK_STATE_ROOT: path.join(TMP, 'state') } as NodeJS.ProcessEnv;
|
||||
const at = (e: NodeJS.ProcessEnv) => { const l = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in l) throw new Error(l.message); return l; };
|
||||
|
||||
test('no Captain drive: the private project-user folder; one Captain drive with approvals/: that directory; two: back to project-user; GSTACK_APPROVALS_DIR wins over both', () => {
|
||||
expect(captainApprovalsDir(base)).toBeUndefined();
|
||||
expect(at(base)).toMatchObject({ mirror_source: 'project-user drive' });
|
||||
expect(at(base).mirror).toBe(path.join(drive, 'project-user-gstack-garry', 'approvals', at(base).slug, path.basename(at(base).file)));
|
||||
fs.mkdirSync(path.join(drive, 'captain-minder'), { recursive: true });
|
||||
expect(captainApprovalsDir(base)).toBeUndefined();
|
||||
fs.mkdirSync(path.join(drive, 'captain-minder', 'approvals'));
|
||||
expect(captainApprovalsDir(base)).toBe(path.join(drive, 'captain-minder', 'approvals'));
|
||||
expect(at(base)).toMatchObject({ mirror_source: 'captain drive', mirror: path.join(drive, 'captain-minder', 'approvals', at(base).slug, path.basename(at(base).file)) });
|
||||
fs.mkdirSync(path.join(drive, 'captain-other', 'approvals'), { recursive: true });
|
||||
expect(captainApprovalsDir(base)).toBeUndefined();
|
||||
expect(at(base)).toMatchObject({ mirror_source: 'project-user drive' });
|
||||
fs.rmSync(path.join(drive, 'captain-other'), { recursive: true });
|
||||
const explicit = path.join(TMP, 'explicit');
|
||||
expect(at({ ...base, GSTACK_APPROVALS_DIR: explicit })).toMatchObject({ mirror_source: 'GSTACK_APPROVALS_DIR', mirror: path.join(explicit, at(base).slug, path.basename(at(base).file)) });
|
||||
expect(at({ ...base, GSTACK_APPROVALS_MIRROR: '0' }).mirror).toBeUndefined();
|
||||
expect(captainApprovalsDir({ ...base, GSTACK_CAPY: undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the other mirror roots are read: a row mirrored under project-user before the Captain created approvals/ stays visible, and path names both', () => {
|
||||
const h = fs.mkdtempSync(path.join(TMP, 'home-'));
|
||||
const d = path.join(h, '.capy', 'drive');
|
||||
fs.mkdirSync(path.join(d, 'project-user-gstack-garry', 'autoplan-checkpoints'), { recursive: true });
|
||||
const e = { ...base, HOME: h, GSTACK_STATE_ROOT: path.join(TMP, 'state-ra') } as NodeJS.ProcessEnv;
|
||||
const before = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in before) throw new Error(before.message);
|
||||
expect(before.read_also).toEqual([]);
|
||||
fs.mkdirSync(path.dirname(before.mirror!), { recursive: true });
|
||||
fs.writeFileSync(before.mirror!, JSON.stringify(row({}, 'lane-x')) + '\n');
|
||||
fs.mkdirSync(path.join(d, 'captain-minder', 'approvals'), { recursive: true });
|
||||
const after = locate({ cwd: ROOT, env: e, branch: 'lane-x' }); if ('code' in after) throw new Error(after.message);
|
||||
expect(after.mirror_source).toBe('captain drive');
|
||||
expect(after.read_also).toEqual([before.mirror!]);
|
||||
expect(readRecord(after).rows.map(r => r.id)).toEqual([row({}, 'lane-x').id]);
|
||||
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-approvals'), 'path', '--branch', 'lane-x'], { cwd: ROOT, env: { ...process.env, ...e } as Record<string, string>, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.stdout).toContain(`MIRROR: ${after.mirror} (captain drive)`);
|
||||
expect(r.stdout).toContain(`READ_ALSO: ${before.mirror}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E16 end to end: every gate-producing command prints the cost, and the briefs carry it', () => {
|
||||
/** Both voices challenge the same item: the gate recommends the plan change (b) and `rec` would reopen phases. */
|
||||
function gateRun(name: string, extraFinding: Record<string, unknown> = {}, o: { challenge?: boolean; json?: boolean } = {}): string {
|
||||
const repo = path.join(TMP, name);
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
expect(spawnSync('git', ['init', '-q', '-b', 'lane-z', repo], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
const out = startSmallRun(repo);
|
||||
const challenge = o.challenge === false ? [] : [{ id: 'F3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes' }];
|
||||
const native = JSON.stringify([
|
||||
{ id: 'F1', severity: 'High', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'print PASS/FAIL per component', ...extraFinding }, ...challenge]);
|
||||
const outside = JSON.stringify([
|
||||
{ id: 'O1', severity: 'Critical', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 14, fix: 'exit non-zero on any FAIL', ...extraFinding },
|
||||
...challenge.map(c => ({ ...c, id: 'O3', fix: 'Stack the lanes.' }))]);
|
||||
let last = '';
|
||||
for (const phase of ['ceo', 'eng']) {
|
||||
const n = autoplan(['next', '--out', out]);
|
||||
expect(n.status).toBe(0);
|
||||
review(path.join(out, `${phase}-native-prompt.md`), { FIXTURE_FINDINGS: native });
|
||||
review(path.join(out, `${phase}-outside-prompt.md`), { FIXTURE_FINDINGS: outside });
|
||||
const ids = attemptsOf(out, phase);
|
||||
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0);
|
||||
last = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.json && phase === 'eng' ? ['--json'] : [])]).stdout;
|
||||
}
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
if (o.json) {
|
||||
const payload = JSON.parse(last.trim().split('\n').pop()!);
|
||||
expect(payload.status).toBe('gate_pending');
|
||||
expect(payload.gate_cost).toMatchObject({ attempts: 4, unknown_count: 4, rec_voices: payload.gate_cost.rec_reopens.length * 2 });
|
||||
expect(typeof payload.gate_cost.spent_usd).toBe('number');
|
||||
} else {
|
||||
expect(last).toMatch(/^GATE_SPEND: spent=\d+\.\d\d worst_case=\d+\.\d\d cap=\S+ headroom=\S+ attempts=4; 4 host-subagent attempt/m);
|
||||
expect(last).toMatch(/^GATE_REC_COST: rec reopens /m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
test('the closing submit prints GATE_SPEND/GATE_REC_COST (text) or gate_cost (--json); the owner brief carries the Cost so far sentence and render --captain a ### Cost section above the relaying trailer', () => {
|
||||
const out = gateRun('plain', {}, { challenge: false, json: true });
|
||||
const brief = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', out], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
|
||||
expect(brief.stdout).toMatch(/Cost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
|
||||
const captain = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--captain', '--out', out, '--allow-incomplete'], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
|
||||
expect(captain.stdout).toMatch(/### Cost\n\nCost so far: spent \d+\.\d\d, worst case \d+\.\d\d \(/);
|
||||
expect(captain.stdout).toContain('Taking every recommendation reopens nothing: no further spend.');
|
||||
// the relaying agent sends everything above the trailer, so the price must sit above it
|
||||
expect(captain.stdout.indexOf('### Cost')).toBeLessThan(captain.stdout.indexOf('_For the relaying agent, not the person:_'));
|
||||
expect(captain.stdout.indexOf('_For the relaying agent, not the person:_')).toBeGreaterThan(0);
|
||||
const changing = gateRun('changing');
|
||||
const rendered = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', changing], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 30_000 });
|
||||
expect(rendered.stdout).toMatch(/Taking every recommendation reopens up to [a-z, ]+: at most \+\d+\.\d\d more\./);
|
||||
});
|
||||
|
||||
test('run at a pending gate prints the gate lines once: text mode carries GATE_SPEND/GATE_REC_COST, --json exactly one payload with gate_cost', () => {
|
||||
const out = gateRun('run-gate', {}, { challenge: false });
|
||||
const models = ['--native-model', 'anthropic/claude-opus-4-7', '--outside-model', 'openai/gpt-6-astra'];
|
||||
const text = autoplan(['run', '--out', out, ...models]);
|
||||
expect(text.status).toBe(0);
|
||||
expect(text.stdout.match(/^GATE_SPEND: /gm)?.length).toBe(1);
|
||||
expect(text.stdout.match(/^GATE_REC_COST: /gm)?.length).toBe(1);
|
||||
const json = autoplan(['run', '--out', out, ...models, '--json']);
|
||||
expect(json.status).toBe(0);
|
||||
const objects = json.stdout.trim().split('\n').filter(l => l.startsWith('{'));
|
||||
expect(objects.length).toBe(1);
|
||||
expect(JSON.parse(objects[0]!)).toMatchObject({ status: 'gate_pending', gate_cost: { rec_reopens: [] } });
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
});
|
||||
});
|
||||
@@ -128,13 +128,13 @@ describe('per-case host metadata', () => {
|
||||
for (const id of Object.keys(E2E_HOSTS)) expect(Object.hasOwn(E2E_TOUCHFILES, id), id).toBe(true);
|
||||
});
|
||||
|
||||
test('classifies 52 adapter-only, 33 Claude-specific, 26 rework and 1 host-neutral case', () => {
|
||||
test('classifies 52 adapter-only, 34 Claude-specific, 26 rework and 1 host-neutral case', () => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const support of Object.values(E2E_HOSTS)) counts[support] = (counts[support] ?? 0) + 1;
|
||||
expect(counts.both).toBe(52);
|
||||
expect(counts.rework).toBe(26);
|
||||
expect(counts['host-neutral']).toBe(1);
|
||||
expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(33);
|
||||
expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(34);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Subprocess tests for bin/gstack-decision-trail: the TSV decision trail
|
||||
* (header once, append-only, one-line cells, spreadsheet formula guard), the
|
||||
* start-row rule and its idempotent rerun, and the --run admission check.
|
||||
*/
|
||||
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";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BIN = path.join(ROOT, "bin", "gstack-decision-trail");
|
||||
const TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
||||
|
||||
let tmp: string;
|
||||
let file: string;
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-decision-trail-"));
|
||||
file = path.join(tmp, "run", "decisions.tsv");
|
||||
});
|
||||
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
function run(...args: string[]) {
|
||||
const r = spawnSync(process.execPath, [BIN, ...args], { cwd: ROOT, encoding: "utf8", timeout: 20_000 });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
const rows = () => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(l => l.split("\t"));
|
||||
|
||||
describe("gstack-decision-trail append", () => {
|
||||
test("first append creates the parent directory, the header and one stamped row", () => {
|
||||
const r = run(file, "frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
|
||||
const [header, row] = rows();
|
||||
expect(header).toEqual(["ts", "phase", "decision", "why", "evidence", "result"]);
|
||||
expect(row!.slice(1)).toEqual(["frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out"]);
|
||||
expect(row![0]).toMatch(TS_RE);
|
||||
});
|
||||
|
||||
test("later appends add rows and never a second header; an empty existing file gets the header", () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "");
|
||||
run(file, "a", "b", "c", "d", "e");
|
||||
run(file, "f", "g", "h", "i", "j");
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all.filter(r => r[0] === "ts")).toHaveLength(1);
|
||||
expect(all[2]!.slice(1)).toEqual(["f", "g", "h", "i", "j"]);
|
||||
});
|
||||
|
||||
test("cells lose tabs and line breaks, and a leading =, +, - or @ gets a quote so a spreadsheet cannot run it", () => {
|
||||
run(file, "probe", "=HYPERLINK(\"http://evil\")", "two\nlines\there\r", "-", "@SUM(A1)");
|
||||
const row = rows()[1]!;
|
||||
expect(row).toHaveLength(6);
|
||||
expect(row.slice(1)).toEqual(["probe", "'=HYPERLINK(\"http://evil\")", "two lines here ", "'-", "'@SUM(A1)"]);
|
||||
run(file, "+1", "plain", "plain", "plain", "plain");
|
||||
expect(rows()[2]![1]).toBe("'+1");
|
||||
});
|
||||
|
||||
test("a cell equal to -h or --help is data, a trail without a final newline or with CRLF rows is repaired on append, and a repeated header is never a row", () => {
|
||||
const r = run(file, "phase", "--help", "why", "evidence", "-h");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
|
||||
expect(rows()[1]!.slice(1)).toEqual(["phase", "'--help", "why", "evidence", "'-h"]);
|
||||
fs.appendFileSync(file, "x\ty\tz");
|
||||
run(file, "p2", "d2", "w", "e", "r");
|
||||
const all = rows();
|
||||
expect(all[2]).toEqual(["x", "y", "z"]);
|
||||
expect(all[3]!.slice(1)).toEqual(["p2", "d2", "w", "e", "r"]);
|
||||
const crlf = path.join(tmp, "crlf.tsv");
|
||||
fs.writeFileSync(crlf, "ts\tphase\tdecision\twhy\tevidence\tresult\r\n2026-01-01T00:00:00Z\ta\tb\tc\td\te\r\nts\tphase\tdecision\twhy\tevidence\tresult\r\n");
|
||||
const start = run("start", crlf, "run-1");
|
||||
expect(start.stdout).toBe(`appended start row 2 for run run-1 to ${crlf}\n`);
|
||||
expect(fs.readFileSync(crlf, "utf8").split("\n").filter(Boolean).pop()!.split("\t")[3]).toBe("1 earlier row (2026-01-01T00:00:00Z..2026-01-01T00:00:00Z) written by other runs");
|
||||
});
|
||||
|
||||
test("a blank file gets a header, and a BOM before the header does not make it a row", () => {
|
||||
const blank = path.join(tmp, "blank.tsv");
|
||||
fs.writeFileSync(blank, "\n");
|
||||
expect(run(blank, "p", "d", "w", "e", "r").status).toBe(0);
|
||||
expect(run(blank, "p2", "d", "w", "e", "r").status).toBe(0);
|
||||
const lines = fs.readFileSync(blank, "utf8").split("\n");
|
||||
expect(lines[1]).toBe("ts\tphase\tdecision\twhy\tevidence\tresult");
|
||||
expect(lines[2]!.split("\t").slice(1)).toEqual(["p", "d", "w", "e", "r"]);
|
||||
expect(lines.filter(l => l === "ts\tphase\tdecision\twhy\tevidence\tresult")).toHaveLength(1);
|
||||
const bom = path.join(tmp, "bom.tsv");
|
||||
fs.writeFileSync(bom, "\uFEFFts\tphase\tdecision\twhy\tevidence\tresult\n");
|
||||
expect(run("--run", "r1", bom, "p", "d", "w", "e", "r").status).toBe(0);
|
||||
expect(fs.readFileSync(bom, "utf8").split("\n").filter(Boolean)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a run id with whitespace or quotes is a usage error on start and on --run", () => {
|
||||
expect(run("start", file, "a\tb").status).toBe(2);
|
||||
expect(run("start", file, "a b").status).toBe(2);
|
||||
expect(run("--run", "x'y", file, "a", "b", "c", "d", "e").status).toBe(2);
|
||||
expect(run("start", file, "jam_01ABC.turn-2:x/y@z").status).toBe(0);
|
||||
expect(fs.readFileSync(file, "utf8").split("\n")).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("wrong arity, an unknown option and a bare call are usage errors; --help exits 0", () => {
|
||||
expect(run(file, "only", "four", "cells", "here").status).toBe(2);
|
||||
expect(run("--bogus", file, "a", "b", "c", "d", "e").status).toBe(2);
|
||||
expect(run().status).toBe(2);
|
||||
expect(run(file, "start", "a", "b", "c", "d").status).toBe(2);
|
||||
const help = run("--help");
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain("gstack-decision-trail start <file> <run-id>");
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-decision-trail start", () => {
|
||||
test("a start row names the earlier rows and the run; rerunning it for the same run is a no-op", () => {
|
||||
run(file, "a", "b", "c", "d", "e");
|
||||
run(file, "f", "g", "h", "i", "j");
|
||||
const first = run("start", file, "jam_A");
|
||||
expect(first.status).toBe(0);
|
||||
expect(first.stdout).toBe(`appended start row 3 for run jam_A to ${file}\n`);
|
||||
const start = rows()[3]!;
|
||||
expect(start[1]).toBe("start");
|
||||
expect(start[2]).toBe("run jam_A joins this trail");
|
||||
expect(start[3]).toMatch(/^2 earlier rows \(\d{4}-.*\.\.\d{4}-.*\) written by other runs$/);
|
||||
expect(start[4]).toBe("run:jam_A");
|
||||
expect(start[5]).toBe("open");
|
||||
|
||||
const again = run("start", file, "jam_A");
|
||||
expect(again.status).toBe(0);
|
||||
expect(again.stdout).toBe(`start row for run jam_A already present at ${file}:4\n`);
|
||||
expect(rows()).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("a start row on an empty trail opens it, and another run's start row follows a prior run's", () => {
|
||||
expect(run("start", file, "run-1").stdout).toBe(`appended start row 1 for run run-1 to ${file}\n`);
|
||||
expect(rows()[1]!.slice(1)).toEqual(["start", "run run-1 opens this trail", "no earlier rows", "run:run-1", "open"]);
|
||||
run("--run", "run-1", file, "work", "did a thing", "because", "commit 1", "ok");
|
||||
expect(run("start", file, "run-2").status).toBe(0);
|
||||
expect(rows().map(r => r[1])).toEqual(["phase", "start", "work", "start"]);
|
||||
// run-1's row is older than run-2's start, so run-1 is a new run again and needs a new start row.
|
||||
expect(run("start", file, "run-1").stdout).toContain("appended start row 4");
|
||||
});
|
||||
|
||||
test("--run refuses an append until the run's start row is the newest one, and admits a fresh trail", () => {
|
||||
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
|
||||
run("start", file, "other");
|
||||
const refused = run("--run", "r9", file, "a", "b", "c", "d", "e");
|
||||
expect(refused.status).toBe(3);
|
||||
expect(refused.stderr).toContain("DECISION_TRAIL_START_REQUIRED");
|
||||
expect(refused.stderr).toContain(`gstack-decision-trail start ${file} r9`);
|
||||
expect(rows()).toHaveLength(3);
|
||||
run("start", file, "r9");
|
||||
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
|
||||
expect(rows()).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("start with the wrong arity is a usage error", () => {
|
||||
expect(run("start", file).status).toBe(2);
|
||||
expect(run("start", file, "r", "extra").status).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { DOC_PATH, fixtureDocs, gitAt, repoSnapshot } from './helpers/docsync-fixture';
|
||||
import { docsCommandAllowed, docsCompletedRead, docsNativeInterface, docsSessionOptions, docsToolFailures } from './helpers/docsync-observer';
|
||||
import { docsCommandAllowed, docsCompletedRead, docsNativeInterface, docsSessionOptions, docsToolFailures, docsWriteFailures } from './helpers/docsync-observer';
|
||||
import { parseNDJSON, type SkillTestResult } from './helpers/session-runner';
|
||||
|
||||
let fixture: ReturnType<typeof fixtureDocs>;
|
||||
@@ -501,3 +501,66 @@ test('the docs interface names the Claude config dir and private actor state as
|
||||
expect(guidance).toContain('judge a child only by the output it returns');
|
||||
expect(guidance).toContain('Every read stays off actor-state.json');
|
||||
});
|
||||
|
||||
// /ship Step 14.5's continue path (PR 2 of the convergence lane) runs three own-tree housekeeping commands and writes
|
||||
// the receipt's docs record inside the repository. The closed interface declares exactly those, byte for byte with the
|
||||
// generated section, and nothing adjacent: a reworded exclude line, a bare rm, another receipt subcommand or another
|
||||
// write under .gstack/ stay outside it (gate: ship-docsync-completion/current red twice at 775d9804 before they were declared).
|
||||
test('Step 14.5 housekeeping commands and the docs record are declared exactly as the generated section writes them', () => {
|
||||
const { DOCS_AUDIT_RECORD, DOCS_AUDIT_RECORD_PATHS, DOCS_AUDIT_RESET, DOCS_TMP_EXCLUDE, docsCapsCommand } = require('./helpers/docsync-interface') as typeof import('./helpers/docsync-interface');
|
||||
const section = fs.readFileSync(path.resolve(import.meta.dir, '..', 'ship/sections/documentation.md'), 'utf8');
|
||||
expect(section).toContain(DOCS_TMP_EXCLUDE);
|
||||
expect(section).toContain(DOCS_AUDIT_RESET);
|
||||
expect(section).toContain('gstack-ship-receipt docs-caps --base <base>');
|
||||
const skills = fixture.skills.split(path.sep).join('/');
|
||||
const caps = docsCapsCommand(skills);
|
||||
const interfaceText = docsNativeInterface(fixture);
|
||||
for (const command of [DOCS_TMP_EXCLUDE, DOCS_AUDIT_RESET, caps, docsCapsCommand('~/.claude/skills/gstack')]) {
|
||||
if (command === caps) expect(interfaceText).toContain(command);
|
||||
expect(docsCommandAllowed(command, fixture)).toBe(true);
|
||||
expect(docsToolFailures(nativeResult(command, 'DOCS_CAP: none declared'), fixture, [], true)).toEqual([]);
|
||||
}
|
||||
for (const command of [DOCS_TMP_EXCLUDE.replace("'/.gstack/tmp/'", "'/.gstack/'"), `${DOCS_TMP_EXCLUDE}; git add -A`, 'rm -f .gstack/tmp', 'rm -rf .gstack/tmp/docs-audit.json',
|
||||
`${skills}/bin/gstack-ship-receipt docs-caps --base feature/docs`, '~/.claude/skills/other/bin/gstack-ship-receipt docs-caps --base main', `${skills}/bin/gstack-ship-receipt write --docs @${DOCS_AUDIT_RECORD}`, `${skills}/bin/gstack-ship-receipt docs-caps --base main --json`]) {
|
||||
expect(docsCommandAllowed(command, fixture)).toBe(false);
|
||||
}
|
||||
const write = (file: string): SkillTestResult => {
|
||||
const parsed = parseNDJSON([
|
||||
{ type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'tool_use', id: 'docs-write', name: 'Write', input: { file_path: file, content: '{"status":"current","scope":"handbook"}' } }] } },
|
||||
{ type: 'user', parent_tool_use_id: null, message: { content: [{ type: 'tool_result', tool_use_id: 'docs-write', content: 'ok', is_error: false }] } },
|
||||
].map(event => JSON.stringify(event)));
|
||||
return { ...parsed, exitReason: 'success' } as unknown as SkillTestResult;
|
||||
};
|
||||
expect(docsToolFailures(write(path.join(fixture.repo, DOCS_AUDIT_RECORD)), fixture, [], false)).toEqual([]);
|
||||
expect(docsToolFailures(write(path.join(fixture.repo, DOCS_AUDIT_RECORD)), fixture, [], true)).toEqual([]);
|
||||
expect(docsToolFailures(write(path.join(fixture.repo, DOC_PATH)), fixture, [], true)).toEqual(['read-only docs write attempt']);
|
||||
// The kernel observer sees the exclude file and the record's directories in every scenario that runs the Entry, the fault cases'
|
||||
// unseeded first attempts included, so the shared list is permitted without each caller naming it; anything else under .git or .gstack is not.
|
||||
const observed = (paths: string[]) => ({ complete: true, failures: [], events: paths.map(p => ({ path: p, mask: 0x2, cookie: 0, at: 0 })), changed: paths, before: {}, after: {}, limits: [] });
|
||||
expect(docsWriteFailures(observed(['.qa-state/.observer-check', ...DOCS_AUDIT_RECORD_PATHS]), [])).toEqual([]);
|
||||
expect(docsWriteFailures(observed(['.git/info/attributes']), [])).toEqual(['forbidden docs write: .git/info/attributes']);
|
||||
expect(docsWriteFailures(observed(['.gstack/tmp/review-loop.json']), [DOC_PATH])).toEqual(['forbidden docs write: .gstack/tmp/review-loop.json']);
|
||||
// The Write tool lands the record through `<record>.tmp.<pid>.<12 hex>` + rename (gate: both lifecycle cases red at 0230fc2 on exactly
|
||||
// that sibling). The sibling is admitted only as the cookie-paired source of a move onto the record that leaves nothing behind.
|
||||
const temp = `${DOCS_AUDIT_RECORD}.tmp.564.6161e6c7f0b7`;
|
||||
const moved = (edits: { path: string; mask: number; cookie?: number }[], after: Record<string, string> = {}) => ({
|
||||
complete: true, failures: [], events: edits.map((e, at) => ({ path: e.path, mask: e.mask, cookie: e.cookie ?? 0, at })), changed: Object.keys(after), before: {}, after, limits: [],
|
||||
});
|
||||
const landing = [{ path: temp, mask: 0x100 }, { path: temp, mask: 0x2 }, { path: temp, mask: 0x8 }, { path: temp, mask: 0x40, cookie: 7 }, { path: DOCS_AUDIT_RECORD, mask: 0x80, cookie: 7 }];
|
||||
expect(docsWriteFailures(moved(landing), [])).toEqual([]);
|
||||
expect(docsWriteFailures(moved(landing.slice(1)), [])).toEqual([]);
|
||||
expect(docsWriteFailures(moved(landing, { [temp]: '0:' + 'a'.repeat(64) }), [])).toEqual([`forbidden docs write: ${temp}`]);
|
||||
expect(docsWriteFailures(moved(landing.slice(0, 3)), [])).toEqual([`forbidden docs write: ${temp}`]);
|
||||
expect(docsWriteFailures(moved([...landing.slice(0, 4), { path: '.gstack/tmp/other.json', mask: 0x80, cookie: 7 }]), [])).toEqual([`forbidden docs write: ${temp}`, 'forbidden docs write: .gstack/tmp/other.json']);
|
||||
expect(docsWriteFailures(moved([...landing.slice(0, 3), { path: temp, mask: 0x40, cookie: 7 }, { path: DOCS_AUDIT_RECORD, mask: 0x80, cookie: 7 }, { path: temp, mask: 0x100 }]), [])).toEqual([`forbidden docs write: ${temp}`]);
|
||||
expect(docsWriteFailures(moved([{ path: `${DOC_PATH}.tmp.564.6161e6c7f0b7`, mask: 0x40, cookie: 9 }, { path: DOCS_AUDIT_RECORD, mask: 0x80, cookie: 9 }]), [])).toEqual([`forbidden docs write: ${DOC_PATH}.tmp.564.6161e6c7f0b7`]);
|
||||
expect(docsToolFailures(write(path.join(fixture.repo, '.gstack/tmp/review-loop.json')), fixture, [], false)).toEqual(['non-document product write attempt', 'write outside docs fixture authority']);
|
||||
expect(docsToolFailures(write(path.join(fixture.repo, '.gstack/ship-policy.json')), fixture, [], false)).toEqual(['non-document product write attempt', 'write outside docs fixture authority']);
|
||||
expect(gitAt(fixture.repo, 'rev-parse', '--verify', 'origin/main')).toBe(gitAt(fixture.repo, 'rev-parse', '--verify', 'main'));
|
||||
// The declared gate must run from the fixture install: bin/gstack-ship-receipt imports ../lib/*, so the install carries lib/ too.
|
||||
const gate = spawnSync('bun', ['--no-env-file', '--config=/dev/null', path.join(fixture.skills, 'bin/gstack-ship-receipt'), 'docs-caps', '--base', 'main'],
|
||||
{ cwd: fixture.repo, encoding: 'utf8', env: { ...process.env, ...fixture.env, HOME: fixture.home }, timeout: 60_000 });
|
||||
expect(gate.stderr).toBe('');
|
||||
expect(gate.stdout.trim()).toBe('DOCS_CAP: none declared');
|
||||
expect(gate.status).toBe(0);
|
||||
});
|
||||
@@ -148,6 +148,8 @@ const SCANNER_EXEMPT: Record<string, string> = {
|
||||
'fetches the user\'s own repo\'s base branch for version-claim freshness — a user-repo dev-workflow op, not gstack-state egress',
|
||||
'bin/gstack-version-bump':
|
||||
'git fetch appears only in an error-message string',
|
||||
'bin/gstack-worktree-audit':
|
||||
'read-only audit: best-effort fetch of the user\'s own repo\'s base branch so the MERGED column is current (skipped with --no-fetch) — a user-repo dev-workflow op like gstack-next-version\'s, not gstack-state egress',
|
||||
'bin/gstack-state-matrix':
|
||||
'git fetch appears only in an error-message string (the fix the user runs when origin/<base> has no merge base)',
|
||||
'bin/gstack-redact-prepush':
|
||||
|
||||
@@ -141,7 +141,7 @@ test('live periodic census fits the declared CI wall including setup', () => {
|
||||
|
||||
test('registered allocation is deterministic and preserves every discovered file', () => {
|
||||
const files = collectPaidTestFiles();
|
||||
expect(files).toHaveLength(110); // + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0);
|
||||
expect(files).toHaveLength(111); // + skill-e2e-ship-frozen-scope (frozen review scope, GSTA-113 PR 2); + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0);
|
||||
expect(files).toContain('test/skill-e2e-ship-skip.test.ts');
|
||||
const m = livePlan(files);
|
||||
expect(livePlan([...files].reverse())).toEqual(m);
|
||||
|
||||
@@ -327,6 +327,8 @@ describe('parity with the measured prototype', () => {
|
||||
const REVIEWED_SUPERSET: Record<string, string> = {
|
||||
setup: './setup, $ROOT/setup and join(..., "setup") exec edges (plan A2-2); setup is a full-gate file, so the closure stops there',
|
||||
'lib/bun-isolation.ts': 'GSTA-56: every bun launcher (script-invocation, restamp, qa-deadline, the hooks) imports it, so it reaches every consumer of those launchers',
|
||||
'lib/ship-receipt-docs.ts': 'bin/gstack-ship-receipt imports it (the docs audit record and doc caps), so it reaches every consumer of the receipt binary',
|
||||
'lib/ship-receipt-prose.ts': 'bin/gstack-ship-receipt imports it (read --check-prose), so it reaches every consumer of the receipt binary',
|
||||
};
|
||||
|
||||
test('the shipped map equals or is a reviewed superset of the prototype map on this checkout', () => {
|
||||
|
||||
Vendored
+12
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Context-budget ratchet ceilings (~tokens). Regenerate: bun test/helpers/capture-context-budget.ts. Headroom: alwaysOnTotal x1.05, eagerPerInvocation x1.1. Graded by test/context-budget-ratchet.test.ts via lib/context-bill.ts checkBudget.",
|
||||
"alwaysOnTotal": 7542,
|
||||
"alwaysOnTotal": 7769,
|
||||
"eagerPerInvocation": {
|
||||
"autoplan": 11289,
|
||||
"benchmark": 7813,
|
||||
@@ -9,7 +9,7 @@
|
||||
"browser-skills/hackernews-frontpage": 371,
|
||||
"canary": 7738,
|
||||
"captain": 6551,
|
||||
"capy/skill/gstack": 3899,
|
||||
"capy/skill/gstack": 4115,
|
||||
"careful": 1239,
|
||||
"codex": 9890,
|
||||
"context-restore": 6078,
|
||||
@@ -17,18 +17,18 @@
|
||||
"cso": 4902,
|
||||
"design-consultation": 10513,
|
||||
"design-html": 8926,
|
||||
"design-motion": 10409,
|
||||
"design-motion": 4724,
|
||||
"design-review": 30295,
|
||||
"design-shotgun": 9318,
|
||||
"deslop-shared-libs": 3411,
|
||||
"devex-review": 15303,
|
||||
"diagram": 4268,
|
||||
"diagram": 4286,
|
||||
"document-generate": 6013,
|
||||
"document-release": 4454,
|
||||
"eval-plan": 4842,
|
||||
"eval-plan": 4887,
|
||||
"freeze": 1115,
|
||||
"gstack": 5326,
|
||||
"gstack-upgrade": 6212,
|
||||
"gstack": 5350,
|
||||
"gstack-upgrade": 6297,
|
||||
"guard": 1000,
|
||||
"health": 5816,
|
||||
"investigate": 7642,
|
||||
@@ -38,10 +38,10 @@
|
||||
"ios-qa": 5905,
|
||||
"ios-sync": 2465,
|
||||
"land-and-deploy": 13585,
|
||||
"landing-report": 3209,
|
||||
"landing-report": 3269,
|
||||
"learn": 2931,
|
||||
"make-pdf": 5204,
|
||||
"office-hours": 20792,
|
||||
"office-hours": 19139,
|
||||
"open-gstack-browser": 4872,
|
||||
"openclaw/skills/gstack-openclaw-ceo-review": 2764,
|
||||
"openclaw/skills/gstack-openclaw-investigate": 1429,
|
||||
@@ -52,16 +52,16 @@
|
||||
"plan-design-review": 17262,
|
||||
"plan-devex-review": 12215,
|
||||
"plan-eng-review": 11369,
|
||||
"plan-tune": 8947,
|
||||
"plan-tune": 9215,
|
||||
"qa": 7644,
|
||||
"qa-only": 5726,
|
||||
"retro": 13244,
|
||||
"retro": 13484,
|
||||
"review": 15417,
|
||||
"scrape": 7312,
|
||||
"setup-browser-cookies": 3507,
|
||||
"setup-deploy": 5738,
|
||||
"setup-gbrain": 10009,
|
||||
"ship": 15899,
|
||||
"ship": 15913,
|
||||
"skillify": 5849,
|
||||
"spec": 9136,
|
||||
"sync-gbrain": 9749,
|
||||
|
||||
+5
-4
@@ -524,7 +524,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
|
||||
## Step 12: Version bump (auto-decide)
|
||||
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
|
||||
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
@@ -544,9 +544,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Repair alone never re-bumps.
|
||||
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
|
||||
matches base. Reconcile the manual edit, then reclassify.
|
||||
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
|
||||
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
|
||||
unprefixed title in Step 18 and log `"version":null` in Step 20.
|
||||
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
|
||||
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
|
||||
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
|
||||
`"version":null` in Step 20.
|
||||
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
|
||||
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
|
||||
|
||||
|
||||
+5
-4
@@ -524,7 +524,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
|
||||
## Step 12: Version bump (auto-decide)
|
||||
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
|
||||
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
@@ -544,9 +544,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Repair alone never re-bumps.
|
||||
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
|
||||
matches base. Reconcile the manual edit, then reclassify.
|
||||
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
|
||||
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
|
||||
unprefixed title in Step 18 and log `"version":null` in Step 20.
|
||||
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
|
||||
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
|
||||
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
|
||||
`"version":null` in Step 20.
|
||||
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
|
||||
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
|
||||
|
||||
|
||||
+5
-4
@@ -523,7 +523,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
|
||||
## Step 12: Version bump (auto-decide)
|
||||
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
|
||||
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
@@ -544,9 +544,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Repair alone never re-bumps.
|
||||
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
|
||||
matches base. Reconcile the manual edit, then reclassify.
|
||||
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
|
||||
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
|
||||
unprefixed title in Step 18 and log `"version":null` in Step 20.
|
||||
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
|
||||
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
|
||||
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
|
||||
`"version":null` in Step 20.
|
||||
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
|
||||
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
|
||||
|
||||
|
||||
+5
-4
@@ -525,7 +525,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
|
||||
## Step 12: Version bump (auto-decide)
|
||||
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
|
||||
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
|
||||
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
@@ -546,9 +546,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Repair alone never re-bumps.
|
||||
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
|
||||
matches base. Reconcile the manual edit, then reclassify.
|
||||
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
|
||||
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
|
||||
unprefixed title in Step 18 and log `"version":null` in Step 20.
|
||||
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
|
||||
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
|
||||
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
|
||||
`"version":null` in Step 20.
|
||||
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
|
||||
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
|
||||
|
||||
|
||||
+4
-1
@@ -103,6 +103,7 @@
|
||||
{ "file": "lib/evidence-bundle.ts", "movedFrom": "(new: portable evidence bundle, execution identity and ancestor check, C3/C7)" },
|
||||
{ "file": "lib/tree-receipt.ts", "movedFrom": "(new: tree equality modulo stamps and per-gate reuse, C3)" },
|
||||
{ "file": "lib/ship-receipt.ts", "movedFrom": "(new: the ship receipt block, PR-body placement and adoption census, C5)" },
|
||||
{ "file": "lib/ship-receipt-docs.ts", "movedFrom": "(new: the receipt's docs record, the owner exception and the policy doc caps, kept out of lib/ship-receipt.ts; convergence lane PR 2)" },
|
||||
{ "file": "lib/spend-ledger.ts", "movedFrom": "(new: the spend cap admission ledger reserve/settle/release and the file lock, B2)" },
|
||||
{ "file": "lib/outside-voice-runner.ts", "movedFrom": "(new: the outside-voice adapter interface, model families and runner selection, B3)" },
|
||||
{ "file": "lib/outside-voice-api.ts", "movedFrom": "(new: the direct OpenAI/Anthropic api runner labeled supplied-input review, B3)" },
|
||||
@@ -159,7 +160,9 @@
|
||||
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
|
||||
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
|
||||
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" }
|
||||
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
|
||||
{ "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" },
|
||||
{ "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }
|
||||
],
|
||||
"residualFiles": [
|
||||
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Free contracts of gstack-coauthor-trailers: distinct authors of a range in
|
||||
* first-appearance order, the checkout's own identity and --exclude left out,
|
||||
* one Co-authored-by line each, and an idempotent --message append. Commits are
|
||||
* written through fast-import so their authors are the stream's, whatever
|
||||
* identity the host forces on `git commit`.
|
||||
*/
|
||||
import { afterAll, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
|
||||
const BIN = path.resolve(import.meta.dir, '../bin/gstack-coauthor-trailers');
|
||||
const temps: string[] = [];
|
||||
afterAll(() => { for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
const ENV = {
|
||||
...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_COMMITTER_NAME: 'Porter', GIT_COMMITTER_EMAIL: 'porter@example.invalid', GIT_AUTHOR_NAME: 'Porter', GIT_AUTHOR_EMAIL: 'porter@example.invalid',
|
||||
};
|
||||
|
||||
function git(cwd: string, args: string[], input?: string) {
|
||||
const r = spawnSync('git', args, { cwd, env: ENV, encoding: 'utf8', timeout: 30_000, input });
|
||||
if (r.status !== 0) throw new Error(r.stderr);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
/** The committer identity git reports in this checkout (whatever the host forces); the bin must never list it. */
|
||||
function selfEmail(cwd: string): string { return /<([^>]*)>/.exec(git(cwd, ['var', 'GIT_COMMITTER_IDENT']))![1]!; }
|
||||
|
||||
function repo() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coauthor-'));
|
||||
temps.push(dir);
|
||||
git(dir, ['init', '-q', '-b', 'main']);
|
||||
const self = selfEmail(dir);
|
||||
let mark = 0;
|
||||
const commit = (branch: string, file: string, msg: string, author: string, from?: string) => {
|
||||
mark++;
|
||||
const at = `${1700000000 + mark} +0000`;
|
||||
const parent = from ? git(dir, ['rev-parse', from]) : '';
|
||||
const stream = `commit refs/heads/${branch}\nmark :${mark}\nauthor ${author} ${at}\ncommitter Porter <${self}> ${at}\ndata ${Buffer.byteLength(msg)}\n${msg}\n`
|
||||
+ (parent ? `from ${parent}\n` : '') + `M 100644 inline ${file}\ndata ${Buffer.byteLength(msg) + 1}\n${msg}\n\n`;
|
||||
git(dir, ['fast-import', '--quiet', '--date-format=raw', '--force'], stream);
|
||||
};
|
||||
commit('main', 'a.txt', 'seed', `Porter <${self}>`);
|
||||
git(dir, ['update-ref', 'refs/remotes/origin/main', 'refs/heads/main']);
|
||||
git(dir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']);
|
||||
commit('port/contrib', 'b.txt', 'contributor fix', 'Ada Contributor <Ada@Example.org>', 'refs/heads/main');
|
||||
commit('port/contrib', 'c.txt', 'second contributor', 'Bo Helper <bo@example.org>', 'refs/heads/port/contrib');
|
||||
commit('port/contrib', 'd.txt', 'ada again', 'Ada Contributor <ada@example.org>', 'refs/heads/port/contrib');
|
||||
commit('port/contrib', 'e.txt', 'porter follow-up', `Porter <${self}>`, 'refs/heads/port/contrib');
|
||||
git(dir, ['checkout', '-q', 'port/contrib']);
|
||||
return dir;
|
||||
}
|
||||
const run = (cwd: string, args: string[]) => runScriptSync(BIN, args, { cwd, env: ENV, encoding: 'utf8', timeout: 60_000 });
|
||||
|
||||
test('the default range is origin/HEAD..HEAD; authors print once each in first-appearance order without the porter', () => {
|
||||
const dir = repo();
|
||||
expect(git(dir, ['log', '--format=%ae', 'origin/main..HEAD']).split('\n')).toHaveLength(4);
|
||||
const r = run(dir, []);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.stdout).toBe('Co-authored-by: Ada Contributor <Ada@Example.org>\nCo-authored-by: Bo Helper <bo@example.org>\n');
|
||||
expect(r.stderr.trim()).toBe('COAUTHORS: 2 in origin/main..HEAD');
|
||||
const json = JSON.parse(run(dir, ['--json', '--exclude', 'BO@example.org']).stdout);
|
||||
expect(json.trailers).toEqual(['Co-authored-by: Ada Contributor <Ada@Example.org>']);
|
||||
expect(run(dir, ['HEAD~1..HEAD']).stdout).toBe('');
|
||||
});
|
||||
|
||||
test('--message appends only the missing trailers and is idempotent; a bad range exits 1 and a bad flag 2', () => {
|
||||
const dir = repo();
|
||||
const file = path.join(dir, 'MSG');
|
||||
fs.writeFileSync(file, 'port: contributor fix\n\nCo-authored-by: ada contributor <ADA@example.org>\n');
|
||||
expect(run(dir, ['--message', file]).status).toBe(EXIT.ok);
|
||||
const expected = 'port: contributor fix\n\nCo-authored-by: ada contributor <ADA@example.org>\nCo-authored-by: Bo Helper <bo@example.org>\n';
|
||||
expect(fs.readFileSync(file, 'utf8')).toBe(expected);
|
||||
expect(spawnSync('git', ['interpret-trailers', '--parse'], { cwd: dir, input: expected, encoding: 'utf8', timeout: 30_000 }).stdout.trim().split('\n')).toHaveLength(2);
|
||||
const plain = path.join(dir, 'PLAIN');
|
||||
fs.writeFileSync(plain, 'port: contributor fix\n');
|
||||
expect(run(dir, ['--message', plain]).status).toBe(EXIT.ok);
|
||||
expect(fs.readFileSync(plain, 'utf8')).toBe('port: contributor fix\n\nCo-authored-by: Ada Contributor <Ada@Example.org>\nCo-authored-by: Bo Helper <bo@example.org>\n');
|
||||
const crlf = path.join(dir, 'CRLF');
|
||||
fs.writeFileSync(crlf, 'port: contributor fix\r\n\r\nCo-authored-by: ada contributor <ADA@example.org>\r\n# comment\r\n');
|
||||
expect(run(dir, ['--message', crlf]).status).toBe(EXIT.ok);
|
||||
const parsed = spawnSync('git', ['interpret-trailers', '--parse', crlf], { cwd: dir, encoding: 'utf8', timeout: 30_000 }).stdout;
|
||||
expect(parsed).toContain('ada contributor');
|
||||
expect(parsed).toContain('Bo Helper');
|
||||
expect(fs.readFileSync(crlf, 'utf8')).toMatch(/Co-authored-by: Bo Helper <bo@example.org>\r?\n# comment/);
|
||||
const again = run(dir, ['--message', file, '--json']);
|
||||
expect(JSON.parse(again.stdout).appended).toEqual([]);
|
||||
expect(fs.readFileSync(file, 'utf8')).toBe(expected);
|
||||
expect(run(dir, ['nope..HEAD']).status).toBe(EXIT.fail);
|
||||
expect(run(dir, ['--bogus']).status).toBe(EXIT.usage);
|
||||
});
|
||||
|
||||
test('--base takes a bare branch, an origin ref or a sha; a slash-named default branch still resolves through origin/; a leading dash is refused', () => {
|
||||
const dir = repo();
|
||||
const sha = git(dir, ['rev-parse', 'origin/main']);
|
||||
for (const base of ['main', 'origin/main', sha]) {
|
||||
const r = run(dir, ['--base', base]);
|
||||
expect(r.status, base).toBe(EXIT.ok);
|
||||
expect(r.stdout, base).toBe('Co-authored-by: Ada Contributor <Ada@Example.org>\nCo-authored-by: Bo Helper <bo@example.org>\n');
|
||||
}
|
||||
expect(run(dir, ['--base', 'main']).stderr.trim()).toBe('COAUTHORS: 2 in origin/main..HEAD');
|
||||
git(dir, ['update-ref', 'refs/remotes/origin/release/1.2', 'refs/remotes/origin/main']);
|
||||
git(dir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/release/1.2']);
|
||||
const slash = run(dir, []);
|
||||
expect(slash.status).toBe(EXIT.ok);
|
||||
expect(slash.stderr.trim()).toBe('COAUTHORS: 2 in origin/release/1.2..HEAD');
|
||||
const dash = run(dir, ['--base', '--output=/dev/null']);
|
||||
expect(dash.status).toBe(EXIT.usage);
|
||||
expect(dash.stderr).toContain('cannot start with -');
|
||||
});
|
||||
@@ -6,8 +6,9 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { DEFAULT_POLICY, POLICY_KEYS, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy';
|
||||
import { DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue } from '../lib/ship-queue-train';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
@@ -32,8 +33,11 @@ describe('validatePolicy (schema + containment, pure)', () => {
|
||||
expect(codes({ pr_count: { default_pr_count: 0, split_requires_owner: 'yes' } })).toEqual(['POLICY_INVALID:pr_count.default_pr_count', 'POLICY_INVALID:pr_count.split_requires_owner']);
|
||||
expect(codes({ review_scope: 'loose' })).toEqual(['POLICY_INVALID:review_scope', 'POLICY_INVALID:review_scope']);
|
||||
expect(codes({ review_scope: 'all' })).toEqual(['POLICY_INVALID:review_scope']);
|
||||
expect(codes({ review_scope: 'all', min_gstack_version: '1.91.249.0' })).toEqual([]);
|
||||
expect(codes({ review_scope: 'diff-plus-blocking', min_gstack_version: '1.91.249.0' })).toEqual([]);
|
||||
expect(codes({ review_scope: 'all', min_gstack_version: REVIEW_SCOPE_KEY_VERSION })).toEqual([]);
|
||||
expect(codes({ review_scope: 'diff-plus-blocking', min_gstack_version: REVIEW_SCOPE_KEY_VERSION })).toEqual([]);
|
||||
// The constant is the release that first read the key; any earlier minimum would admit a gstack that fails on the unknown key.
|
||||
const earlier = REVIEW_SCOPE_KEY_VERSION.replace(/(\d+)\.0$/, (_, p) => `${Number(p) - 7}.0`);
|
||||
expect(codes({ review_scope: 'all', min_gstack_version: earlier })).toEqual(['POLICY_INVALID:review_scope']);
|
||||
expect(codes({ review_scope: 'frozen' })).toEqual([]);
|
||||
expect(codes([])).toEqual(['POLICY_INVALID:']);
|
||||
});
|
||||
@@ -70,6 +74,25 @@ describe('validatePolicy (schema + containment, pure)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy key versions against the CHANGELOG', () => {
|
||||
test('an unreleased key names a release newer than every heading the checkout can see', () => {
|
||||
// The base branch's CHANGELOG counts when the checkout has it (a depth-1 CI clone does not): a stamp-at-merge
|
||||
// branch carries main's headings only after the slot merge, and a release stamped under another number
|
||||
// leaves the constant naming a release that never read the key, which the stamper moves by hand.
|
||||
const root = path.join(import.meta.dir, '..');
|
||||
const base = spawnSync('git', ['show', 'origin/main:CHANGELOG.md'], { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
const text = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8') + (base.status === 0 ? base.stdout : '');
|
||||
const parse = (v: string) => v.split('.').map(Number);
|
||||
const cmp = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]! || a[3]! - b[3]!;
|
||||
const headings = [...text.matchAll(/^## \[(\d+\.\d+\.\d+\.\d+)\]/gm)].map(m => m[1]!);
|
||||
const newest = headings.map(parse).sort(cmp).at(-1)!;
|
||||
for (const [key, version] of Object.entries(POLICY_KEY_INTRODUCED)) {
|
||||
if (headings.includes(version)) continue;
|
||||
expect(cmp(parse(version), newest), `${key} is introduced in ${version}, which has no CHANGELOG heading and is not newer than the newest heading ${newest.join('.')}; an unreleased key names the release that will stamp it`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack's own .gstack/ship-policy.json", () => {
|
||||
test('validates and declares the merge-train block against workflows that exist', () => {
|
||||
const root = path.join(import.meta.dir, '..');
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* PR 2 of the /ship + /review convergence lane: a repository with no version
|
||||
* source or CHANGELOG ships with `version: "none"` (E18); the documentation
|
||||
* audit's owner-accept path and the policy's doc caps (E11); the carry's git
|
||||
* floor, named patch-id refusal and branch-touched walk; the digit-bounded
|
||||
* stamp replacement.
|
||||
*/
|
||||
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 { CARRY_NAMED_PATHS, extractReceipt, planCarry, stampOnlyText } from '../lib/ship-receipt';
|
||||
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
import { DOCS_CAPS_KEY_VERSION, validatePolicy } from '../lib/ship-policy';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
import { gitArgvIn } from './helpers/scratch-repo';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = (opts: Parameters<typeof makeFixtureRepo>[0] = {}) => { const r = makeFixtureRepo({ prefix: 'ship-receipt-2-', ...opts }); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
/** A repository with no VERSION, no package.json, no CHANGELOG and no policy: the shape E18 names. */
|
||||
function bareRepo(): FixtureRepo {
|
||||
const repo = fixture();
|
||||
for (const f of ['VERSION', 'package.json', 'package-lock.json', 'CHANGELOG.md', 'gen.js', 'generated', 'docs', '.gstack']) fs.rmSync(path.join(repo.work, f), { recursive: true, force: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'README.md'), '# fixture\n');
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-qm', 'no version source');
|
||||
repo.git('push', '-q', '-f', 'origin', 'main');
|
||||
return repo;
|
||||
}
|
||||
|
||||
describe('E18: a repository without a version source or CHANGELOG', () => {
|
||||
test('write records version "none"; read --body and result --body accept it', () => {
|
||||
const repo = bareRepo();
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--session-kind', 'unattended', '--body', body]);
|
||||
expect(w.status).toBe(EXIT.ok);
|
||||
expect(w.err.trim()).toContain(`head=${head.slice(0, 12)} version=none base=main`);
|
||||
const parsed = extractReceipt(w.out);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(parsed.receipt).toMatchObject({ pr: 5, head, version: 'none' });
|
||||
const read = runBin(repo, 'gstack-ship-receipt', ['read', '--body', body, '--json']);
|
||||
expect(read.status).toBe(EXIT.ok);
|
||||
expect(JSON.parse(read.out).version).toBe('none');
|
||||
const out = path.join(repo.root, 'run');
|
||||
const result = runBin(repo, 'gstack-ship-receipt', ['result', '--body', body, '--out', out]);
|
||||
expect(result.status).toBe(EXIT.ok);
|
||||
expect(result.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${out}`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none');
|
||||
});
|
||||
|
||||
test('a repository with a version source keeps its version, and one with only a CHANGELOG records no version rather than "none"', () => {
|
||||
const repo = fixture();
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']);
|
||||
expect(JSON.parse(w.out).version).toBe('1.2.3.0');
|
||||
for (const f of ['VERSION', 'package.json', 'package-lock.json']) fs.rmSync(path.join(repo.work, f));
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-qm', 'drop the version files, keep the changelog');
|
||||
const only = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']);
|
||||
expect(only.status).toBe(EXIT.ok);
|
||||
expect('version' in JSON.parse(only.out)).toBe(false);
|
||||
});
|
||||
|
||||
test('"none" means absent: a package.json version is used, and a malformed VERSION, a policy stamp file or an unreadable base keep the no-version path', () => {
|
||||
const version = (repo: FixtureRepo, args: string[] = []) => JSON.parse(runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json', ...args]).out).version;
|
||||
const manifest = bareRepo();
|
||||
fs.writeFileSync(path.join(manifest.work, 'package.json'), JSON.stringify({ name: 'x', version: '4.5.6' }) + '\n');
|
||||
manifest.git('add', '-A');
|
||||
manifest.git('commit', '-qm', 'manifest only');
|
||||
expect(version(manifest)).toBe('4.5.6');
|
||||
const malformed = bareRepo();
|
||||
fs.writeFileSync(path.join(malformed.work, 'VERSION'), 'not-a-version\n');
|
||||
malformed.git('add', '-A');
|
||||
malformed.git('commit', '-qm', 'malformed VERSION');
|
||||
expect(version(malformed)).toBeUndefined();
|
||||
const stamped = fixture({ policy: { ...BASE_POLICY, stamp_files: ['RELEASE'] } });
|
||||
fs.writeFileSync(path.join(stamped.work, 'RELEASE'), 'build 7\n');
|
||||
for (const f of ['VERSION', 'package.json', 'package-lock.json', 'CHANGELOG.md']) fs.rmSync(path.join(stamped.work, f));
|
||||
stamped.git('add', '-A');
|
||||
stamped.git('commit', '-qm', 'only a policy stamp file');
|
||||
expect(version(stamped)).toBeUndefined();
|
||||
const unreachable = bareRepo();
|
||||
unreachable.git('update-ref', '-d', 'refs/remotes/origin/main');
|
||||
expect(version(unreachable)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('E11: the documentation audit record and the doc caps', () => {
|
||||
test('parseReceiptDocs: blocked without an exception is DOCS_AUDIT_BLOCKED; malformed input is RECEIPT_INVALID; a full record parses', () => {
|
||||
expect(parseReceiptDocs({ status: 'blocked', scope: 'docs/' })).toMatchObject({ ok: false, code: 'DOCS_AUDIT_BLOCKED' });
|
||||
expect(parseReceiptDocs({ status: 'current', scope: 'docs/' })).toEqual({ ok: true, docs: { status: 'current', scope: 'docs/' } });
|
||||
for (const bad of [null, [], 'current', { status: 'done', scope: 'x' }, { status: 'current' }, { status: 'current', scope: '' }, { status: 'current', scope: 'x', extra: 1 },
|
||||
{ status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: '2026-10-26', via: 'r' } }, { status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: 'yesterday', via: 'r', reason: 'w' } },
|
||||
{ status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: '2026-10-26', via: 'r', reason: 'w', more: 1 } }]) {
|
||||
expect(parseReceiptDocs(bad)).toMatchObject({ ok: false, code: 'RECEIPT_INVALID' });
|
||||
}
|
||||
const full = parseReceiptDocs({ status: 'blocked', scope: 'docs/, README.md', exception: { accepted_by: ' owner ', date: '2026-10-26', via: 'PR #12 comment', reason: 'the removed page is republished next release' } });
|
||||
expect(full).toEqual({ ok: true, docs: { status: 'blocked', scope: 'docs/, README.md', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #12 comment', reason: 'the removed page is republished next release' } } });
|
||||
expect(docsExceptionLine((full as any).docs)).toBe('Documentation exception: accepted by owner 2026-10-26 via PR #12 comment: the removed page is republished next release');
|
||||
expect(docsExceptionLine({ status: 'current', scope: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
test('write --docs refuses a blocked audit with no exception and a body without the exception line, and records the exception otherwise', () => {
|
||||
const repo = fixture();
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Documentation\n\nAudit blocked on docs/removed.md.\n');
|
||||
const common = ['write', '--pr', '9', '--base', 'main', '--session-kind', 'unattended', '--body', body];
|
||||
const blocked = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"blocked","scope":"docs/"}']);
|
||||
expect(blocked.status).toBe(EXIT.refused);
|
||||
expect(blocked.err).toContain('DOCS_AUDIT_BLOCKED');
|
||||
expect(blocked.err).toContain('troubleshooting.md#docs-audit-blocked');
|
||||
expect(fs.readFileSync(body, 'utf8')).not.toContain('gstack-ship-receipt');
|
||||
const docsFile = path.join(repo.root, 'docs.json');
|
||||
fs.writeFileSync(docsFile, JSON.stringify({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } }));
|
||||
const noLine = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '@' + docsFile]);
|
||||
expect(noLine.status).toBe(EXIT.refused);
|
||||
expect(noLine.err).toContain('does not carry the exception line verbatim');
|
||||
expect(noLine.err).toContain('"Documentation exception: accepted by owner 2026-10-26 via PR #9 review: removed page republished in the next release"');
|
||||
fs.appendFileSync(body, 'Documentation exception: accepted by owner 2026-10-26 via PR #9 review: removed page republished in the next release\n');
|
||||
const ok = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '@' + docsFile, '--review-loop', '{"passes":1,"blocking_found":0,"deferred":0}']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const receipt = extractReceipt(fs.readFileSync(body, 'utf8'));
|
||||
expect(receipt.errors).toEqual([]);
|
||||
expect((receipt.receipt as any).docs).toEqual({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } });
|
||||
expect(Object.keys(receipt.receipt!).slice(-2)).toEqual(['docs', 'review_loop']);
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--body', body]).status).toBe(EXIT.ok);
|
||||
const malformed = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"done","scope":"docs/"}']);
|
||||
expect(malformed.status).toBe(EXIT.fail);
|
||||
expect(malformed.err).toContain('RECEIPT_INVALID');
|
||||
});
|
||||
|
||||
test('checkDocsCaps and docs-caps: one line per cap, over or absent exits 1 with DOCS_AUDIT_BLOCKED, no caps prints none declared', () => {
|
||||
expect(checkDocsCaps([{ path: 'a.md', max_bytes: 10 }, { path: 'b.md', max_bytes: 10 }, { path: 'c.md', max_bytes: 10 }], p => ({ 'a.md': 10, 'b.md': 11 } as Record<string, number>)[p] ?? null).map(docsCapLine))
|
||||
.toEqual(['DOCS_CAP: a.md 10/10 ok', 'DOCS_CAP: b.md 11/10 over', 'DOCS_CAP: c.md absent/10 over']);
|
||||
const repo = fixture();
|
||||
const none = runBin(repo, 'gstack-ship-receipt', ['docs-caps']);
|
||||
expect(none.status).toBe(EXIT.ok);
|
||||
expect(none.out.trim()).toBe('DOCS_CAP: none declared');
|
||||
const policy = path.join(repo.root, 'policy.json');
|
||||
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: 'docs/notes.md', max_bytes: 4096 }, { path: 'CHANGELOG.md', max_bytes: 16 }, { path: 'docs/missing.md', max_bytes: 10 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }));
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy]);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
const changelogBytes = fs.statSync(path.join(repo.work, 'CHANGELOG.md')).size;
|
||||
expect(r.out.trim().split('\n')).toEqual([
|
||||
`DOCS_CAP: docs/notes.md ${fs.statSync(path.join(repo.work, 'docs/notes.md')).size}/4096 ok`,
|
||||
`DOCS_CAP: CHANGELOG.md ${changelogBytes}/16 over`,
|
||||
'DOCS_CAP: docs/missing.md absent/10 over',
|
||||
]);
|
||||
expect(r.err).toContain('DOCS_AUDIT_BLOCKED');
|
||||
expect(r.err).toContain('2 of 3 documentation caps over: CHANGELOG.md, docs/missing.md');
|
||||
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: 'docs/notes.md', max_bytes: 4096 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }));
|
||||
const ok = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy, '--json']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(JSON.parse(ok.out)).toMatchObject({ over: 0, caps: [{ path: 'docs/notes.md', verdict: 'ok' }] });
|
||||
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: '../escape.md', max_bytes: 0 }] } }));
|
||||
const bad = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy]);
|
||||
expect(bad.status).toBe(EXIT.fail);
|
||||
expect(bad.err).toContain('POLICY_CONTAINMENT');
|
||||
expect(bad.err).toContain('docs.caps[0].max_bytes');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', path.join(repo.root, 'nope.json')]).status).toBe(EXIT.usage);
|
||||
});
|
||||
|
||||
test('docs-caps reads the caps from the base policy, so a branch that raises its own cap in the working tree still reports over', () => {
|
||||
const repo = fixture({ policy: { ...BASE_POLICY, docs: { caps: [{ path: 'CHANGELOG.md', max_bytes: 16 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION } });
|
||||
const raised = JSON.stringify({ ...BASE_POLICY, docs: { caps: [{ path: 'CHANGELOG.md', max_bytes: 1_000_000 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }, null, 2) + '\n';
|
||||
fs.writeFileSync(path.join(repo.work, '.gstack', 'ship-policy.json'), raised);
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-qm', 'raise my own cap');
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--base', 'main']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out.trim()).toBe(`DOCS_CAP: CHANGELOG.md ${fs.statSync(path.join(repo.work, 'CHANGELOG.md')).size}/16 over`);
|
||||
expect(r.err).toContain('DOCS_AUDIT_BLOCKED');
|
||||
const explicit = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', path.join(repo.work, '.gstack', 'ship-policy.json')]);
|
||||
expect(explicit.status).toBe(EXIT.ok);
|
||||
repo.git('update-ref', '-d', 'refs/remotes/origin/main');
|
||||
const gone = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--base', 'main']);
|
||||
expect(gone.status).toBe(EXIT.refused);
|
||||
expect(gone.err).toContain('POLICY_SOURCE_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('the policy docs key validates its shape and needs min_gstack_version', () => {
|
||||
const codes = (raw: Record<string, unknown>) => validatePolicy(raw, { gstackVersion: '9.9.9.9' }).map(e => `${e.code}:${e.path}`);
|
||||
expect(codes({ docs: null })).toEqual([]);
|
||||
expect(codes({ docs: { caps: [] } })).toEqual(['POLICY_INVALID:docs']);
|
||||
expect(codes({ docs: { caps: [{ path: 'README.md', max_bytes: 100 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION })).toEqual([]);
|
||||
expect(codes({ docs: { caps: 'README.md' }, min_gstack_version: DOCS_CAPS_KEY_VERSION })).toEqual(['POLICY_INVALID:docs.caps']);
|
||||
expect(codes({ docs: { caps: [{ path: '/etc/x', max_bytes: 1.5, other: 1 }], limit: 1 }, min_gstack_version: DOCS_CAPS_KEY_VERSION }))
|
||||
.toEqual(['POLICY_INVALID:docs.limit', 'POLICY_INVALID:docs.caps[0].other', 'POLICY_CONTAINMENT:docs.caps[0].path', 'POLICY_INVALID:docs.caps[0].max_bytes']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit-bounded stamps', () => {
|
||||
test('stampOnlyText replaces the version only at digit boundaries', () => {
|
||||
const change = (before: string, after: string) => stampOnlyText({ before, after, oldVersion: '1.2.3', newVersion: '1.2.4' });
|
||||
expect(change('v1.2.3 and 11.2.3 and 1.2.34\n', 'v1.2.4 and 11.2.3 and 1.2.34\n')).toBe(true);
|
||||
expect(change('v1.2.3 and 11.2.3\n', 'v1.2.4 and 11.2.4\n')).toBe(false);
|
||||
expect(change('1.2.34\n', '1.2.44\n')).toBe(false);
|
||||
});
|
||||
|
||||
test('planCarry names at most CARRY_NAMED_PATHS paths', () => {
|
||||
const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`);
|
||||
const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' });
|
||||
expect(plan.ok).toBe(false);
|
||||
expect((plan as any).message).toContain(`(+3 more)`);
|
||||
expect((plan as any).message.split(', ').length).toBe(CARRY_NAMED_PATHS);
|
||||
});
|
||||
|
||||
// The old-git stand-in is a sh script on a colon-joined PATH, which CreateProcess cannot run on Windows.
|
||||
test.skipIf(process.platform === 'win32')('carry refuses git below 2.39 by name (RECEIPT_GIT_TOO_OLD) and names a product path the branch touched', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-carry', { expects: '1.2.4.0' });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]).status).toBe(EXIT.ok);
|
||||
fs.writeFileSync(path.join(repo.work, 'src.ts'), 'export const x = 1;\n');
|
||||
repo.git('add', 'src.ts');
|
||||
repo.git('commit', '-qm', 'product change');
|
||||
const shim = path.join(repo.root, 'shim');
|
||||
fs.mkdirSync(shim);
|
||||
fs.writeFileSync(path.join(shim, 'git'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.38.5"; exit 0; fi\nexec /usr/bin/git "$@"\n', { mode: 0o755 });
|
||||
const realGit = gitArgvIn(repo.work, ['--version']).stdout.toString();
|
||||
expect(realGit).toMatch(/git version/);
|
||||
const old = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main'], { PATH: `${shim}:${process.env.PATH}` });
|
||||
expect(old.status).toBe(EXIT.refused);
|
||||
expect(old.err).toContain('RECEIPT_GIT_TOO_OLD');
|
||||
expect(old.err).toContain('git 2.38.5 cannot run patch-id --verbatim; the carry needs git 2.39 or later');
|
||||
expect(fs.readFileSync(body, 'utf8')).toContain('"pr": 3');
|
||||
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(changed.status).toBe(EXIT.refused);
|
||||
expect(changed.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
|
||||
expect(changed.err).toContain('src.ts');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a failing patch-id is a RECEIPT_CARRY_REFUSED naming git\'s error, never a carry', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-patchid', { expects: '1.2.4.0' });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--session-kind', 'x', '--body', body]).status).toBe(EXIT.ok);
|
||||
fs.writeFileSync(path.join(repo.work, 'src.test.ts'), 'export const t = 1;\n');
|
||||
repo.git('add', 'src.test.ts');
|
||||
repo.git('commit', '-qm', 'test-only change');
|
||||
const shim = path.join(repo.root, 'shim');
|
||||
fs.mkdirSync(shim);
|
||||
fs.writeFileSync(path.join(shim, 'git'), '#!/bin/sh\nif [ "$1" = "patch-id" ]; then echo "fatal: patch-id exploded" >&2; exit 128; fi\nexec /usr/bin/git "$@"\n', { mode: 0o755 });
|
||||
const before = fs.readFileSync(body, 'utf8');
|
||||
const broken = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main'], { PATH: `${shim}:${process.env.PATH}` });
|
||||
expect(broken.status).toBe(EXIT.refused);
|
||||
expect(broken.err).toContain('RECEIPT_CARRY_REFUSED');
|
||||
expect(broken.err).toContain('git patch-id --verbatim: fatal: patch-id exploded');
|
||||
expect(fs.readFileSync(body, 'utf8')).toBe(before);
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']).out).toContain('CARRY: carried');
|
||||
});
|
||||
|
||||
test('the carry walk names the branch product path and not a product path a base merge brought in', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-walk', { expects: '1.2.4.0' });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '4', '--base', 'main', '--session-kind', 'x', '--body', body]).status).toBe(EXIT.ok);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
fs.writeFileSync(path.join(repo.work, 'lib-base-only.ts'), 'export const base = 1;\n');
|
||||
repo.git('add', 'lib-base-only.ts');
|
||||
repo.git('commit', '-qm', 'base change');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'pr-walk');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const merged = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(merged.status).toBe(EXIT.ok);
|
||||
expect(merged.out).toContain('CARRY: carried');
|
||||
fs.writeFileSync(path.join(repo.work, 'src.ts'), 'export const x = 2;\n');
|
||||
repo.git('add', 'src.ts');
|
||||
repo.git('commit', '-qm', 'product change after the merge');
|
||||
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(changed.status).toBe(EXIT.refused);
|
||||
expect(changed.err).toMatch(/product diff changed between [0-9a-f]{12} and [0-9a-f]{12}: src\.ts/);
|
||||
expect(changed.err).not.toContain('lib-base-only.ts');
|
||||
});
|
||||
});
|
||||
@@ -303,7 +303,7 @@ export async function captureNativeFirstAuq(opts: NativeAuqCaptureOptions): Prom
|
||||
trustedDirs: [cwd],
|
||||
})), {mode:0o600});
|
||||
const skillPath = path.join(cwd, opts.skillName, 'SKILL.md');
|
||||
const prompt = `The ONLY skill file you may read is this absolute path: ${skillPath}. Do NOT search for, Glob, find, or read any other SKILL.md anywhere — especially nothing under ~/.claude or /Users.
|
||||
const prompt = `The ONLY skill file you may read is this absolute path: ${skillPath}, together with the files in its sections/ directory that it tells you to Read. Do NOT search for, Glob, find, or read any other SKILL.md anywhere — especially nothing under ~/.claude or /Users.
|
||||
|
||||
Read ${skillPath} and follow its workflow for this scenario:
|
||||
|
||||
|
||||
@@ -129,7 +129,8 @@ export function setupPlanCeoDir(opts: {
|
||||
].join('\n'),
|
||||
);
|
||||
fs.mkdirSync(path.join(dir, 'plan-ceo-review'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plan-ceo-review', 'SKILL.md'), opts.skillMd);
|
||||
fs.writeFileSync(path.join(dir, 'plan-ceo-review', 'SKILL.md'),
|
||||
opts.skillMd.replaceAll('~/.claude/skills/gstack/plan-ceo-review/sections/', path.join(dir, 'plan-ceo-review', 'sections') + '/'));
|
||||
if (opts.sectionsFrom && fs.existsSync(opts.sectionsFrom)) {
|
||||
fs.cpSync(opts.sectionsFrom, path.join(dir, 'plan-ceo-review', 'sections'), { recursive: true });
|
||||
}
|
||||
@@ -161,7 +162,8 @@ export function setupSkillDir(opts: {
|
||||
fs.writeFileSync(p, content);
|
||||
}
|
||||
fs.mkdirSync(path.join(dir, opts.skillName), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, opts.skillName, 'SKILL.md'), opts.skillMd);
|
||||
fs.writeFileSync(path.join(dir, opts.skillName, 'SKILL.md'),
|
||||
opts.skillMd.replaceAll(`~/.claude/skills/gstack/${opts.skillName}/sections/`, path.join(dir, opts.skillName, 'sections') + '/'));
|
||||
if (opts.sectionsFrom && fs.existsSync(opts.sectionsFrom)) {
|
||||
fs.cpSync(opts.sectionsFrom, path.join(dir, opts.skillName, 'sections'), { recursive: true });
|
||||
}
|
||||
@@ -467,11 +469,11 @@ export async function captureModeSelectionAuq(opts: {
|
||||
// which silently invalidates a carved-vs-verbose A/B (both sides end up
|
||||
// reading the same global skill). Absolute path + no-wander instruction +
|
||||
// Bash disallowed (so `find /` is impossible) locks it to the planted file.
|
||||
const prompt = `Review the plan using ONLY these two files:
|
||||
const prompt = `Review the plan with this skill and plan:
|
||||
- The skill to follow: ${skillPath}
|
||||
- The plan to review: ${planPath}
|
||||
|
||||
Read ${skillPath} for the review workflow. Do NOT search for, Glob, find, or read any OTHER SKILL.md anywhere on the system — especially nothing under ~/.claude or /Users. The ONLY skill file you may read is the absolute path above.
|
||||
Read ${skillPath} for the review workflow. Do NOT search for, Glob, find, or read any OTHER SKILL.md anywhere on the system — especially nothing under ~/.claude or /Users. The ONLY SKILL.md you may read is the absolute path above; the files in its sections/ directory that it tells you to Read are part of this skill.
|
||||
|
||||
Read ${planPath} — that is the plan to review. It is a standalone plan document, not a codebase. Skip any codebase exploration or system-audit steps.
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// wave's headline capability) grows the union to 1.195x. Deliberate:
|
||||
// the section is on-demand (loads only for Apple store targets), so
|
||||
// per-invocation cost for non-iOS ships is one manifest line.
|
||||
maxSizeRatio: 1.7172, // GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 322,312 / 187,706 = 1.7171. Before: Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays
Line truncated
|
||||
maxSizeRatio: 1.7432, // GSTA-96 hoist merged with v1.91.270.0 (#3487, 2026-10-27): both owner-approved raises together, measured 327,203 / 187,706 = 1.7432. Before: Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton sta
Line truncated
|
||||
},
|
||||
'plan-ceo-review': {
|
||||
skill: 'plan-ceo-review',
|
||||
|
||||
@@ -37,16 +37,18 @@ export function fixtureDocs(scenario: DocsScenario, generatedRoot = process.env.
|
||||
gitAt(repo, 'add', 'README.md', 'AGENTS.md', DOC_PATH, 'app.ts', 'VERSION', 'CHANGELOG.md', 'TODOS.md', 'package.json');
|
||||
gitAt(repo, 'commit', '-m', 'fixture baseline');
|
||||
const base = gitAt(repo, 'rev-parse', 'HEAD');
|
||||
// Steps 0–14 ran before this phase: origin/main exists (Step 14.5's docs-caps reads the base policy there).
|
||||
const remote = path.join(home, 'remote.git');
|
||||
fs.mkdirSync(remote);
|
||||
gitAt(remote, 'init', '--bare', '-b', 'main');
|
||||
gitAt(repo, 'remote', 'add', 'origin', remote);
|
||||
gitAt(repo, 'push', '-q', 'origin', 'main');
|
||||
if (scenario !== 'store') gitAt(repo, 'checkout', '-b', 'feature/docs');
|
||||
if (scenario === 'current') {
|
||||
write(DOC_PATH, '# Widget reference\n\nDefault format: text.\n\nUser-maintained note: KEEP THIS EXACTLY.\n\nSupports plain text output.\n');
|
||||
gitAt(repo, 'add', DOC_PATH);
|
||||
gitAt(repo, 'commit', '-m', 'docs: clarify format');
|
||||
const remote = path.join(home, 'remote.git');
|
||||
fs.mkdirSync(remote);
|
||||
gitAt(remote, 'init', '--bare', '-b', 'main');
|
||||
gitAt(repo, 'remote', 'add', 'origin', remote);
|
||||
gitAt(repo, 'push', '-u', 'origin', 'feature/docs');
|
||||
gitAt(repo, 'push', '-q', '-u', 'origin', 'feature/docs');
|
||||
} else {
|
||||
write('app.ts', 'export const format = "json";\n');
|
||||
gitAt(repo, 'add', 'app.ts');
|
||||
@@ -76,6 +78,12 @@ export function fixtureDocs(scenario: DocsScenario, generatedRoot = process.env.
|
||||
recursive: true,
|
||||
filter: source => !fs.statSync(source).isFile() || fs.statSync(source).size < 5_000_000,
|
||||
});
|
||||
// Step 14.5's cap gate (bin/gstack-ship-receipt docs-caps) imports ../lib/*; an install without lib/ fails the gate
|
||||
// with a module error instead of a DOCS_CAP verdict (gate at 2524ec4: both lifecycle cases blocked on it).
|
||||
fs.cpSync(path.join(DOCSYNC_ROOT, 'lib'), path.join(skills, 'lib'), {
|
||||
recursive: true,
|
||||
filter: source => !fs.statSync(source).isFile() || (/\.(?:ts|json)$/.test(source) && fs.statSync(source).size < 5_000_000),
|
||||
});
|
||||
const state = path.join(home, 'state');
|
||||
fs.mkdirSync(state);
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
|
||||
|
||||
@@ -2,11 +2,26 @@ import * as path from 'node:path';
|
||||
import type { fixtureDocs } from './docsync-fixture';
|
||||
import { docsConfigDir } from './docsync-repo';
|
||||
import { readOnlyInterfaceText } from './read-only-commands';
|
||||
import { freeTextFileBash } from '../../scripts/resolvers/free-text-file';
|
||||
|
||||
/** /ship Step 14.5's own product-tree record: the path, the Entry's reset and the shared exclude line that keeps it out of git. */
|
||||
export const DOCS_AUDIT_RECORD = '.gstack/tmp/docs-audit.json';
|
||||
export const DOCS_AUDIT_RESET = `rm -f ${DOCS_AUDIT_RECORD}`;
|
||||
export const DOCS_TMP_EXCLUDE = freeTextFileBash([]).split('\n').find(line => line.startsWith('_EX='))!;
|
||||
/** The paths the Step 14.5 continue path may create in the product tree: the record, its directories and git's exclude file. */
|
||||
export const DOCS_AUDIT_RECORD_PATHS = ['.gstack', '.gstack/tmp', DOCS_AUDIT_RECORD, '.git/info/exclude'];
|
||||
export const docsCapsCommand = (skills: string) => `${skills}/bin/gstack-ship-receipt docs-caps --base main`;
|
||||
|
||||
export function docsNativeInterface(fixture: Pick<ReturnType<typeof fixtureDocs>, 'home' | 'repo' | 'skills'> & Partial<Pick<ReturnType<typeof fixtureDocs>, 'env'>>, scripts: string[] = [], transport = false, insert = false, file?: string): string {
|
||||
const skills = fixture.skills.split(path.sep).join('/');
|
||||
const state = fixture.env?.GSTACK_HOME ?? path.join(fixture.home, 'state');
|
||||
return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch --show-current, describe with only --tags, --always, --abbrev=<n> or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (a --help is no exception, so never add one to a line of reads): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare <that .json> and --help commands. ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs and those private artifacts; do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations.
|
||||
return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch --show-current, describe with only --tags, --always, --abbrev=<n> or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (a --help is no exception, so never add one to a line of reads): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare <that .json> and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits)
|
||||
${DOCS_TMP_EXCLUDE}
|
||||
then
|
||||
${DOCS_AUDIT_RESET}
|
||||
and the cap gate, which reads origin/main's policy and whose DOCS_CAP lines and exit status decide it
|
||||
${docsCapsCommand(skills)}
|
||||
(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations.
|
||||
|
||||
The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command.
|
||||
|
||||
|
||||
@@ -5,14 +5,33 @@ import { observeQAWrites, type QAWriteObservation } from './qa-functional-observ
|
||||
import { nativeCalls } from './qa-checkpoint-evidence';
|
||||
import type { SkillTestResult } from './session-runner';
|
||||
import { DOC_PATH, type DocsScenario, type fixtureDocs } from './docsync-fixture';
|
||||
import { docsNativeInterface } from './docsync-interface';
|
||||
import { docsNativeInterface, DOCS_AUDIT_RECORD, DOCS_AUDIT_RECORD_PATHS, DOCS_AUDIT_RESET, DOCS_TMP_EXCLUDE, docsCapsCommand } from './docsync-interface';
|
||||
import { sliceBetween } from './skill-fixture';
|
||||
import { readOnlyCommandAllowed } from './read-only-commands';
|
||||
|
||||
export { docsNativeInterface };
|
||||
|
||||
export async function observeDocsWrites(fixture: ReturnType<typeof fixtureDocs>) {
|
||||
return observeQAWrites(fixture.repo, { atomicTargets: [DOC_PATH] });
|
||||
return observeQAWrites(fixture.repo, { atomicTargets: [DOC_PATH, DOCS_AUDIT_RECORD] });
|
||||
}
|
||||
|
||||
/** Temp files the Write/Edit tool renamed onto the receipt record (`<record>.tmp.<pid>.<12 hex>`): created, written and closed
|
||||
* under the record's directory, then moved onto the record by a cookie-paired MOVED_FROM/MOVED_TO, and not left behind. */
|
||||
function docsRecordSources(observation: QAWriteObservation): Set<string> {
|
||||
const sources = new Set<string>();
|
||||
const events = observation.events;
|
||||
for (const [index, event] of events.entries()) {
|
||||
if (event.path !== DOCS_AUDIT_RECORD || event.mask !== 0x80 || !Number.isInteger(event.cookie) || event.cookie <= 0) continue;
|
||||
const pair = events.flatMap((candidate, at) => candidate.cookie === event.cookie ? [at] : []);
|
||||
if (pair.length !== 2 || pair[1] !== index) continue;
|
||||
const source = events[pair[0]];
|
||||
if (source.mask !== 0x40 || !source.path.startsWith(DOCS_AUDIT_RECORD + '.tmp.') || !/^\.tmp\.[1-9]\d*\.[0-9a-f]{12}$/.test(source.path.slice(DOCS_AUDIT_RECORD.length))
|
||||
|| Object.hasOwn(observation.before, source.path) || Object.hasOwn(observation.after, source.path) || observation.changed.includes(source.path)) continue;
|
||||
const lifecycle = events.filter(candidate => candidate.path === source.path);
|
||||
if (lifecycle.at(-1) !== source || lifecycle.some(candidate => candidate !== source && (candidate.cookie !== 0 || ![0x100, 0x2, 0x4, 0x8].includes(candidate.mask)))) continue;
|
||||
sources.add(source.path);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
type DocsWriteContext = {
|
||||
@@ -136,8 +155,9 @@ export function docsWriteFailures(observation: QAWriteObservation, allowed: stri
|
||||
const failures = [...observation.failures];
|
||||
if (!observation.complete) failures.push('incomplete docs write observation');
|
||||
const atomicSources = docsAtomicSources(observation, allowed, context);
|
||||
const recordSources = docsRecordSources(observation);
|
||||
for (const file of new Set([...observation.events.map(e => e.path), ...observation.changed])) {
|
||||
if (file !== '.qa-state/.observer-check' && !allowed.includes(file) && !atomicSources.has(file))
|
||||
if (file !== '.qa-state/.observer-check' && !allowed.includes(file) && !DOCS_AUDIT_RECORD_PATHS.includes(file) && !atomicSources.has(file) && !recordSources.has(file))
|
||||
failures.push(`forbidden docs write: ${file}${atomicSources.deniedAt && path.dirname(file) === path.dirname(DOC_PATH) ? ` (atomic replacement unproven at ${atomicSources.deniedAt})` : ''}`);
|
||||
if (allowed.includes(file) && observation.before[file] && observation.after[file] &&
|
||||
observation.before[file].split(':')[0] !== observation.after[file].split(':')[0]) failures.push(`document mode changed: ${file}`);
|
||||
@@ -230,6 +250,9 @@ function unwrapHarmlessRead(text: string, fixture: ReturnType<typeof fixtureDocs
|
||||
export function docsCommandAllowed(command: string, fixture: ReturnType<typeof fixtureDocs>, scripts: string[] = []): boolean {
|
||||
const text = command.trim();
|
||||
if (docsPreambleCommands(fixture).some(block => block.trim() === text)) return true;
|
||||
// The section prints the cap gate with the ~/.claude/skills/gstack prefix; in the fixture HOME is fixture.home, so that form names the same installed binary.
|
||||
if (text === DOCS_TMP_EXCLUDE || text === DOCS_AUDIT_RESET || text === docsCapsCommand(fixture.skills.split(path.sep).join('/')) ||
|
||||
(text === docsCapsCommand('~/.claude/skills/gstack') && fixture.skills === path.join(fixture.home, '.claude/skills/gstack'))) return true;
|
||||
if (readOnlyCommandAllowed(text, docsReadOnlyScope(fixture)) || docsComposedReadAllowed(text, fixture)) return true;
|
||||
const insert = /^cat (['"]?)([^\s'"]+)\1 >> (['"]?)([^\s'"]+)\3$/.exec(text);
|
||||
if (insert) return [insert[2], insert[4]].every(file => path.isAbsolute(file) && /\.md$/i.test(file) &&
|
||||
@@ -457,8 +480,10 @@ export function docsToolFailures(result: SkillTestResult, fixture: ReturnType<ty
|
||||
const file = path.resolve(fixture.repo, call.input?.file_path ?? '');
|
||||
const actual = actualWritePath(file);
|
||||
const productWrite = within(file, fixture.repo) || (actual !== null && within(actual, repo));
|
||||
if (readOnly && productWrite) failures.push('read-only docs write attempt');
|
||||
const allowedDoc = file === path.join(fixture.repo, DOC_PATH) && actual === authoredDoc;
|
||||
// Step 14.5's receipt record is housekeeping every scenario may write, a read-only audit included; the document stays read-only.
|
||||
const recordWrite = file === path.join(fixture.repo, DOCS_AUDIT_RECORD) && actual === path.join(repo, DOCS_AUDIT_RECORD);
|
||||
if (readOnly && productWrite && !recordWrite) failures.push('read-only docs write attempt');
|
||||
const allowedDoc = (file === path.join(fixture.repo, DOC_PATH) && actual === authoredDoc) || recordWrite;
|
||||
if (productWrite && !allowedDoc) {
|
||||
failures.push('non-document product write attempt');
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ const CLAUDE_ONLY: Readonly<Record<ClaudeOnlyReason, readonly string[]>> = {
|
||||
'plan-design-with-ui-scope', 'plan-devex-finding-floor', 'plan-devex-review-plan-mode', 'plan-mode-no-op'],
|
||||
sdk: ['auq-format-gate', 'investigate-owned-abort', 'investigate-owned-completion', 'investigate-owned-ending-error',
|
||||
'shared-libs-review-index-flags', 'shared-libs-review-lifecycle', 'shared-libs-review-path-eligibility',
|
||||
'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation', 'ship-local-hook-preservation',
|
||||
'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation', 'ship-frozen-scope-convergence', 'ship-local-hook-preservation',
|
||||
'ship-managed-hook-refresh', 'ship-skipped-queued-finding', 'ship-unmanaged-hook-consent'],
|
||||
subagents: ['docsync-spawned', 'review-army-perf-n-plus-one'],
|
||||
'skill-tool': ['router-failure-contradicted-cause', 'router-failure-triage-resolves', 'router-failure-triage-unclear',
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type { CanUseTool, HookCallback, SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { AgentSdkResult, QueryProvider } from './agent-sdk-runner';
|
||||
import type { EvalTestEntry } from './eval-store';
|
||||
import { CAPTURE_MS } from './eval-budgets';
|
||||
import { SHARED_INTERACTIVE_MAX_TURNS } from './shared-libs-eval-fixture';
|
||||
import { runGeneration } from '../../scripts/gen-skill-docs';
|
||||
import { gitArgvIn } from './scratch-repo';
|
||||
|
||||
/**
|
||||
* The native /ship frozen-scope convergence case (docs/ship-policy.md#review-scope).
|
||||
*
|
||||
* The actor runs the generated Step 9.4 and its repeat decision under
|
||||
* review_scope "frozen" against a fixture branch with two CRITICAL findings:
|
||||
* one in the diff (invoice.ts drops its owner check) and one in code the branch
|
||||
* never touched (payments.ts interpolates a card number into SQL). The contract:
|
||||
* the in-diff defect is fixed and committed, the pre-existing one is deferred
|
||||
* and its file left byte-identical, pass 2 reviews only the range since pass 1
|
||||
* and finds nothing, the loop exits through decision 4, and the review log
|
||||
* yields REVIEW_LOOP passes=2 blocking_found=2 deferred=1 fixed=1. Every helper
|
||||
* the model touches (gstack-review-scope, gstack-review-log) is the real binary
|
||||
* in the fixture's owned state root; the adapters only bind their arguments.
|
||||
*/
|
||||
export const SHIP_FROZEN_SCOPE_CASE = 'ship-frozen-scope-convergence';
|
||||
export const SHIP_FROZEN_SCOPE_SUITE = 'ship-frozen-scope';
|
||||
export const FROZEN_SCOPE_FIX_IDENTITY = 'invoice.ts:1:authorization';
|
||||
export const FROZEN_SCOPE_DEFER_IDENTITY = 'payments.ts:2:injection';
|
||||
const ROOT = path.resolve(import.meta.dir, '../..');
|
||||
const SAFE_INVOICE = 'export const canReadInvoice = (owner: string, viewer: string) => owner === viewer;\n';
|
||||
const BROKEN_INVOICE = 'export const canReadInvoice = (_owner: string, _viewer: string) => true;\n';
|
||||
const PAYMENTS = 'export const chargeCard = (db: { run: (sql: string) => void }, card: string, amount: number) =>\n'
|
||||
+ " db.run(`INSERT INTO charges (card, amount) VALUES ('${card}', ${amount})`);\n";
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
const read = (file: string) => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
||||
const json = (file: string) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const write = (file: string, value: unknown) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
||||
|
||||
function command(cwd: string, env: NodeJS.ProcessEnv, executable: string, args: string[], deadline = Infinity) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error('Frozen-scope case deadline exhausted during setup');
|
||||
const result = spawnSync(executable, args, { cwd, env, encoding: 'utf8', timeout: Math.min(20_000, remaining) });
|
||||
if (result.status !== 0 || result.error) throw new Error(result.error?.message ?? (result.stderr || result.stdout));
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function section(text: string, first: string, last: string) {
|
||||
const start = text.indexOf(first);
|
||||
const end = text.indexOf(last, start + first.length);
|
||||
if (start < 0 || end < start || text.indexOf(first, start + first.length) >= 0) throw new Error(`Ambiguous workflow boundary: ${first}`);
|
||||
return text.slice(start, end).trim();
|
||||
}
|
||||
|
||||
/** The generated Step 9.4 (scope, cap, fix, defer, log) and the repeat decision, as /ship renders them for Claude Code. */
|
||||
export async function shipFrozenScopeWorkflow() {
|
||||
const rendered = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'sfrz-render-'));
|
||||
try {
|
||||
const generated = await runGeneration({ host: 'claude', outputRoot: rendered, contentLinkRoot: null, log: () => {} });
|
||||
if (generated.exitCode !== 0) throw new Error('Ship fixture generation failed');
|
||||
const army = read(path.join(rendered, 'ship/sections/review-army.md'));
|
||||
return section(army, '## Step 9.4: Fix-First and persistence', '**Required-probe parent gate:**');
|
||||
} finally { fs.rmSync(rendered, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
export const FROZEN_SCOPE_FINDINGS = [
|
||||
{ fingerprint: FROZEN_SCOPE_FIX_IDENTITY, path: 'invoice.ts', line: 1, severity: 'CRITICAL', category: 'authorization', source: 'checklist',
|
||||
problem: 'canReadInvoice returns true for every viewer, so any caller reads any invoice.', recommended_fix: 'Accept only the invoice owner as the viewer.' },
|
||||
{ fingerprint: FROZEN_SCOPE_DEFER_IDENTITY, path: 'payments.ts', line: 2, severity: 'CRITICAL', category: 'injection', source: 'specialist:security',
|
||||
problem: 'chargeCard interpolates the card value into the SQL text, so a crafted card string runs arbitrary SQL.', recommended_fix: 'Bind card and amount as parameters.' },
|
||||
];
|
||||
|
||||
export function createShipFrozenScopeFixture(workflow: string, root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'sfrz-')), deadline = Infinity) {
|
||||
const routing = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES']
|
||||
.filter(key => process.env[key] !== undefined);
|
||||
if (routing.length) throw new Error(`Refusing ambient Git routing: ${routing.join(', ')}`);
|
||||
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(root, 0o700);
|
||||
const repo = path.join(root, 'project');
|
||||
const home = path.join(root, 'home');
|
||||
const state = path.join(root, 'state');
|
||||
const assets = path.join(root, 'assets');
|
||||
for (const dir of [repo, home, state, assets]) fs.mkdirSync(dir, { mode: 0o700 });
|
||||
const env = { HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, CLAUDE_PLUGIN_DATA: '',
|
||||
CLAUDE_CONFIG_DIR: path.join(root, 'claude-config'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0', GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid',
|
||||
GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid',
|
||||
PATH: `${path.dirname(process.execPath)}:${process.env.PATH ?? ''}` };
|
||||
const git = (...args: string[]) => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error('Frozen-scope case deadline exhausted during setup');
|
||||
const result = gitArgvIn(repo, args, Math.min(10_000, remaining), env);
|
||||
if (result.status !== 0 || result.error) throw new Error(result.error?.message ?? result.stderr.toString());
|
||||
return result.stdout.toString().trim();
|
||||
};
|
||||
git('init', '-q', '-b', 'main');
|
||||
const invoice = path.join(repo, 'invoice.ts');
|
||||
const payments = path.join(repo, 'payments.ts');
|
||||
fs.writeFileSync(invoice, SAFE_INVOICE, { mode: 0o644 });
|
||||
fs.writeFileSync(payments, PAYMENTS, { mode: 0o644 });
|
||||
fs.writeFileSync(path.join(repo, '.gitignore'), '.gstack/tmp/\n', { mode: 0o644 });
|
||||
git('add', '.gitignore', 'invoice.ts', 'payments.ts');
|
||||
git('commit', '-qm', 'Seed invoice authorization and card charges');
|
||||
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
|
||||
git('checkout', '-qb', 'fixture/frozen-scope');
|
||||
fs.writeFileSync(invoice, BROKEN_INVOICE);
|
||||
git('add', 'invoice.ts');
|
||||
git('commit', '-qm', 'Let any viewer read an invoice');
|
||||
const head = git('rev-parse', 'HEAD');
|
||||
const tmp = path.join(repo, '.gstack', 'tmp');
|
||||
fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
|
||||
const findingsPath = path.join(tmp, 'review-findings-p1.json');
|
||||
write(findingsPath, FROZEN_SCOPE_FINDINGS);
|
||||
const workflowPath = path.join(assets, 'workflow.md');
|
||||
fs.writeFileSync(workflowPath, workflow, { mode: 0o600 });
|
||||
const draft = path.join(root, 'review-record.json');
|
||||
const receipts = path.join(root, 'receipts.jsonl');
|
||||
const fullEnv = { ...process.env, ...env };
|
||||
const token = command(repo, fullEnv, path.join(ROOT, 'bin/gstack-review-log'), ['--start', 'review'], deadline).stdout.trim();
|
||||
const startFiles = fs.readdirSync(state, { recursive: true }).filter(file => String(file).endsWith(`/${token}.json`));
|
||||
if (startFiles.length !== 1) throw new Error('Expected one owned real review-start receipt');
|
||||
const start = json(path.join(state, String(startFiles[0])));
|
||||
if (start.repo !== repo || start.branch !== 'fixture/frozen-scope') throw new Error('Review-start receipt has foreign ownership');
|
||||
const commands = Object.fromEntries(['classify', 'defer', 'persist', 'commit', 'repeat', 'continue', 'stop'].map(action =>
|
||||
[action, `${quote(process.execPath)} ${quote(import.meta.path)} --fixture ${quote(root)} ${action}`])) as Record<string, string>;
|
||||
write(path.join(root, 'fixture.json'), { repo, env, invoice, payments, head, tokens: [token], draft, receipts, tmp });
|
||||
const executions: Array<{ tool: string; input: Record<string, unknown>; allowed: boolean; pass: number }> = [];
|
||||
const refusals: string[] = [];
|
||||
const rows = () => read(receipts).trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { action: string; pass: number; head?: string });
|
||||
const pass = () => rows().filter(row => row.action === 'persist').length + 1;
|
||||
const finished = () => rows().some(row => row.action === 'continue' || row.action === 'stop');
|
||||
const readable = new Set([workflowPath, invoice, payments]);
|
||||
const invalid = (tool: string, input: Record<string, unknown>) => {
|
||||
if (finished()) return 'Loop decision already taken';
|
||||
const target = typeof input.file_path === 'string' ? path.resolve(repo, input.file_path) : '';
|
||||
if (tool === 'Read') return target && (readable.has(target) || (target.startsWith(tmp + path.sep) && fs.existsSync(target))) ? undefined : 'Read outside the declared files';
|
||||
if (tool === 'Write') {
|
||||
if (target === draft) return typeof input.content === 'string' && input.content.length <= 16_384 ? undefined : 'Review record too large';
|
||||
if (target === invoice) return typeof input.content === 'string' ? undefined : 'Write needs content';
|
||||
return 'Write outside invoice.ts and the review record';
|
||||
}
|
||||
if (tool === 'Edit') return target === invoice && typeof input.old_string === 'string' && typeof input.new_string === 'string' ? undefined : 'Edit outside invoice.ts';
|
||||
if (tool === 'Bash') return typeof input.command === 'string' && !input.run_in_background && Object.values(commands).includes(input.command.trim()) ? undefined : 'Bash outside the fixture interface';
|
||||
return 'Undeclared tool';
|
||||
};
|
||||
const canUseTool: CanUseTool = async (tool, input) => {
|
||||
const reason = invalid(tool, input as Record<string, unknown>);
|
||||
if (reason) { refusals.push(reason); return { behavior: 'deny', message: reason }; }
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
};
|
||||
const preToolUse: HookCallback = async input => {
|
||||
if (input.hook_event_name !== 'PreToolUse') throw new Error('Unexpected hook');
|
||||
const toolInput = input.tool_input as Record<string, unknown>;
|
||||
const reason = invalid(input.tool_name, toolInput);
|
||||
executions.push({ tool: input.tool_name, input: toolInput, allowed: !reason, pass: pass() });
|
||||
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: reason ? 'deny' : 'allow',
|
||||
...(reason ? { permissionDecisionReason: reason } : {}),
|
||||
...(input.tool_name === 'Bash' && !reason ? { updatedInput: { command: toolInput.command, timeout: 30000, run_in_background: false } } : {}) } };
|
||||
};
|
||||
const prompt = `Load gstack's bounded /ship review-loop path from ${workflowPath}: Step 9.4 and the "Decide whether to repeat Step 9" decisions that follow it. This invocation runs unattended (SESSION_KIND: headless, no owner present) under review_scope policy \`frozen\` with base \`origin/main\`, on branch fixture/frozen-scope in ${repo}. PASS is 1 and CYCLES is 0 on entry. The pass-1 Step 9.3 result is already written at ${findingsPath}; verify each finding against its source (${invoice}, ${payments}) before acting on the classified output. Execute the workflow through its decision, pass by pass, until the decision continues to Step 10 or stops the loop.
|
||||
|
||||
Supported interactions, declared before launch:
|
||||
- Read: ${workflowPath}, ${invoice}, ${payments}, and any file under ${tmp}/ once it exists.
|
||||
- Edit or Write: ${invoice} only, and only to apply a fix for a finding the classified output scopes \`fix\`. ${payments} is never written in this fixture. Write also accepts ${draft}, the item-6 review record of the current pass: the JSON object the gstack-review-log example takes (skill, timestamp, status, issues_found, critical, informational, quality_score 10.0, specialists {}, findings with fingerprint, severity, action and the copied locus, kind and blocking, commit, via "ship", completed, converged, cycles, pass, review_scope, range_base). The record's commit is the short HEAD: ${head.slice(0, 7)} now, and the sha the commit adapter prints after it commits a fix; no git command is needed for it.
|
||||
- Bash accepts exactly these single commands, without chaining, redirection or backgrounding. Each is the bound adapter for the generated helper command it names and does not decide which item or decision applies:
|
||||
${commands.classify} runs gstack-review-scope classify for the current pass against this pass's .gstack/tmp/review-findings-p$PASS.json (--range-base is bound from pass 2 on); it writes .gstack/tmp/review-classified-p$PASS.json and prints the REVIEW_SCOPE line and the classified JSON.
|
||||
${commands.defer} runs gstack-review-scope defer on the current pass's classified file; it writes .gstack/tmp/review-deferred.json and prints the DEFERRED lines and the rendered PR-body sections (gh is absent here, so a deferred finding stays unfiled with its reason).
|
||||
${commands.persist} passes ${draft} to the real gstack-review-log with this pass's REVIEW_START token (--finish); the pass-1 token was captured before this prompt, and the repeat adapter captures the next one.
|
||||
${commands.commit} runs item 4's commit of the fixed file (git add invoice.ts && git commit -m "fix: pre-landing review fixes").
|
||||
${commands.repeat} takes decision 3: it runs the next pass's Steps 5 through 9.3 on the range since the previous pass, writes that pass's .gstack/tmp/review-findings-p$PASS.json and prints the result with the new REVIEW_START captured.
|
||||
${commands.continue} takes decision 4 (no edits in this pass): continue to Step 10 and stop this fixture.
|
||||
${commands.stop} takes decision 1 or 2: STOP the loop and stop this fixture.
|
||||
No owner can answer here, so AskUserQuestion is not available: a finding the classified output scopes \`fix\` is resolved as AUTO-FIX under the Fix-First heuristic, and no finding is skipped. The gate scan of item 2.5, freshness, memory updates and Step 10 itself are outside this fixture. Stop after the continue or stop adapter completes.`;
|
||||
const snapshot = () => ({ invoice: read(invoice), payments: read(payments), originalPayments: PAYMENTS, head, start, refusals, executions, receipts: rows(),
|
||||
workflowSha256: digest(workflow), promptSha256: digest(prompt), stateRoot: state, repo,
|
||||
status: gitArgvIn(repo, ['status', '--porcelain'], 10_000, env).stdout.toString().trim(),
|
||||
headNow: gitArgvIn(repo, ['rev-parse', 'HEAD'], 10_000, env).stdout.toString().trim(),
|
||||
rows: (() => { try { return reviewRows(repo, fullEnv); } catch (error) { return { error: String(error) }; } })(),
|
||||
loop: (() => { try { return loopSummary(repo, fullEnv, start.started_at); } catch (error) { return { error: String(error) }; } })() });
|
||||
return { root, repo, env, workflowPath, findingsPath, invoice, payments, draft, commands, prompt, preToolUse, canUseTool, snapshot };
|
||||
}
|
||||
|
||||
function reviewRows(repo: string, env: NodeJS.ProcessEnv) {
|
||||
const out = command(repo, env, path.join(ROOT, 'bin/gstack-review-read'), []).stdout;
|
||||
return out.split('---CONFIG---')[0]!.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, any>);
|
||||
}
|
||||
|
||||
function loopSummary(repo: string, env: NodeJS.ProcessEnv, since: string) {
|
||||
return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--since', since, '--json']).stdout.trim());
|
||||
}
|
||||
|
||||
export async function shipFrozenScopeFailures(fixture: ReturnType<typeof createShipFrozenScopeFixture>, result: Pick<AgentSdkResult, 'exitReason'>) {
|
||||
const evidence = fixture.snapshot();
|
||||
const failures: string[] = [];
|
||||
const check = (ok: boolean, message: string) => { if (!ok) failures.push(message); };
|
||||
check(result.exitReason === 'success', `actor ended: ${result.exitReason}`);
|
||||
const denied = evidence.executions.filter(event => !event.allowed).map(event => `${event.tool} ${JSON.stringify(event.input).slice(0, 160)}`);
|
||||
check(evidence.refusals.length === 0 && denied.length === 0, `undeclared interaction: ${[...evidence.refusals, ...denied].join('; ')}`);
|
||||
check(evidence.payments === evidence.originalPayments, 'pre-existing payments.ts was edited: the frozen scope fixes only the diff');
|
||||
try {
|
||||
const url = `${pathToFileURL(fixture.invoice).href}?v=${randomUUID()}`;
|
||||
const mod = await import(url) as { canReadInvoice?: (owner: string, viewer: string) => boolean };
|
||||
check(typeof mod.canReadInvoice === 'function' && mod.canReadInvoice('owner', 'owner') === true && mod.canReadInvoice('owner', 'other') === false,
|
||||
'invoice.ts still accepts a viewer other than the owner');
|
||||
} catch (error) { failures.push(`invoice.ts does not load after the fix: ${String(error).split('\n')[0]}`); }
|
||||
const actions = evidence.receipts.map(row => row.action);
|
||||
check(actions.at(-1) === 'continue', `loop did not exit through decision 4 (adapters: ${actions.join(' → ') || 'none'})`);
|
||||
check(actions.filter(action => action === 'repeat').length === 1, 'expected exactly one repeat pass');
|
||||
check(actions.filter(action => action === 'persist').length === 2, 'expected one logged row per pass');
|
||||
const order = (first: string, second: string) => actions.includes(first) && actions.includes(second) && actions.indexOf(first) < actions.indexOf(second);
|
||||
check(order('classify', 'defer') && order('defer', 'persist') && order('persist', 'commit') && order('commit', 'repeat'), 'pass 1 ran its items out of order');
|
||||
check(evidence.status === '' && evidence.headNow !== evidence.head, 'the fix was not committed before pass 2');
|
||||
const rows = Array.isArray(evidence.rows) ? evidence.rows.filter(row => row.skill === 'review' && row.via === 'ship') : [];
|
||||
const [first, second] = rows;
|
||||
check(rows.length === 2 && first?.pass === 1 && second?.pass === 2, `expected pass 1 and pass 2 review rows, found ${rows.map(row => row.pass).join(', ') || 'none'}`);
|
||||
const identity = (f: Record<string, any>) => f?.fingerprint ?? `${f?.path}:${f?.line}`;
|
||||
const fixed = (first?.findings ?? []).find((f: Record<string, any>) => identity(f) === FROZEN_SCOPE_FIX_IDENTITY);
|
||||
const deferred = (first?.findings ?? []).find((f: Record<string, any>) => identity(f) === FROZEN_SCOPE_DEFER_IDENTITY);
|
||||
check(['fixed', 'auto-fixed'].includes(fixed?.action) && fixed?.blocking === true && fixed?.locus === 'diff', 'pass 1 did not record the in-diff defect as fixed');
|
||||
check(deferred?.action === 'deferred' && deferred?.issue === null && deferred?.blocking === true && deferred?.locus === 'outside', 'pass 1 did not record the pre-existing defect as deferred');
|
||||
check(first?.review_scope === 'frozen' && second?.review_scope === 'frozen', 'rows do not carry the frozen policy');
|
||||
check(typeof second?.range_base === 'string' && second.range_base.length >= 7 && evidence.head.startsWith(second.range_base), 'pass 2 did not record the pass-1 head as its range base');
|
||||
check(!(second?.findings ?? []).some((f: Record<string, any>) => ['fixed', 'auto-fixed'].includes(f?.action)) && Number(second?.issues_found) === 0, 'pass 2 recorded fixes or open defects on an empty range review');
|
||||
const loop = evidence.loop as Record<string, any>;
|
||||
check(loop?.passes === 2 && loop?.blocking_found === 2 && loop?.deferred === 1 && loop?.fixed === 1 && loop?.scope === 'frozen',
|
||||
`REVIEW_LOOP summary ${JSON.stringify(loop)} is not passes=2 blocking_found=2 deferred=1 fixed=1 scope=frozen`);
|
||||
return { failures, evidence };
|
||||
}
|
||||
|
||||
export async function runShipFrozenScopeActor(record: (entry: EvalTestEntry) => void, injectedQuery?: QueryProvider, artifactDirectory?: string) {
|
||||
const started = Date.now();
|
||||
const deadline = started + CAPTURE_MS - 20000;
|
||||
const artifacts = artifactDirectory ?? process.env.GSTACK_EVAL_DIR;
|
||||
const evidenceFile = artifacts ? path.join(artifacts, `${SHIP_FROZEN_SCOPE_CASE}-${randomUUID()}.json`) : undefined;
|
||||
let workflow = '';
|
||||
let fixture: ReturnType<typeof createShipFrozenScopeFixture> | undefined;
|
||||
let result: AgentSdkResult | undefined;
|
||||
let failure: unknown;
|
||||
let passed = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const events: SDKMessage[] = [];
|
||||
try {
|
||||
if (!artifacts) throw new Error('Frozen-scope actor requires GSTACK_EVAL_DIR for durable evidence');
|
||||
fs.mkdirSync(artifacts, { recursive: true, mode: 0o700 });
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
||||
const { runAgentSdkTest, resolveClaudeBinary } = await import('./agent-sdk-runner');
|
||||
workflow = await shipFrozenScopeWorkflow();
|
||||
fixture = createShipFrozenScopeFixture(workflow, undefined, deadline);
|
||||
const binary = injectedQuery ? undefined : resolveClaudeBinary();
|
||||
if (!injectedQuery && !binary) throw new Error('Native frozen-scope actor requires the pinned Claude CLI');
|
||||
const owned = fixture;
|
||||
const controller = new AbortController();
|
||||
const expired = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => { const error = new Error('Frozen-scope case deadline exhausted'); controller.abort(error); reject(error); }, Math.max(1, deadline - Date.now()));
|
||||
});
|
||||
const running = runAgentSdkTest({ systemPrompt: { type: 'preset', preset: 'claude_code' }, userPrompt: owned.prompt,
|
||||
workingDirectory: owned.repo, env: owned.env, maxTurns: SHARED_INTERACTIVE_MAX_TURNS, maxRetries: 0,
|
||||
signal: controller.signal, allowedTools: ['Read', 'Write', 'Edit', 'Bash'],
|
||||
settingSources: [], testName: SHIP_FROZEN_SCOPE_CASE, pathToClaudeCodeExecutable: binary ?? undefined,
|
||||
canUseTool: owned.canUseTool,
|
||||
queryProvider: options => {
|
||||
const stream = (injectedQuery ?? query)({ ...options, options: { ...options.options, cwd: owned.repo, allowedTools: [],
|
||||
canUseTool: owned.canUseTool, hooks: { PreToolUse: [{ hooks: [owned.preToolUse] }] } } });
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
return new Proxy(stream, { get(target, property) {
|
||||
if (property === Symbol.asyncIterator) return async function* () {
|
||||
for (let next = await iterator.next(); !next.done; next = await iterator.next()) { events.push(next.value); yield next.value; }
|
||||
};
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
},
|
||||
});
|
||||
result = await Promise.race([running, expired]);
|
||||
const verdict = await shipFrozenScopeFailures(owned, result);
|
||||
if (verdict.failures.length) throw new Error(verdict.failures.join('; '));
|
||||
passed = true;
|
||||
} catch (error) { failure = error; }
|
||||
finally {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const terminal = events.findLast(event => event.type === 'result');
|
||||
const costUsd = typeof terminal?.total_cost_usd === 'number' && Number.isFinite(terminal.total_cost_usd) && terminal.total_cost_usd >= 0 ? terminal.total_cost_usd : null;
|
||||
const billing = { knownCostUsd: costUsd ?? 0, costKnown: costUsd !== null, status: !events.length ? 'not_started' : costUsd !== null ? 'complete' : 'incomplete' };
|
||||
const billingNote = billing.costKnown ? '' : 'Terminal billing is incomplete; actual total cost is unknown. Only known charges are summed; all events are retained.';
|
||||
const output = JSON.stringify({ error: failure === undefined ? undefined : String(failure), prompt: fixture?.prompt, workflow,
|
||||
evidence: fixture?.snapshot(), events, output: result?.output, started, deadline, root: fixture?.root, billing });
|
||||
if (evidenceFile) fs.writeFileSync(evidenceFile, output + '\n', { mode: 0o600 });
|
||||
record({ name: SHIP_FROZEN_SCOPE_CASE, suite: SHIP_FROZEN_SCOPE_SUITE, tier: 'e2e', passed, duration_ms: Date.now() - started,
|
||||
cost_usd: billing.knownCostUsd, transcript: [{ type: 'fixture_billing', cost_known: billing.costKnown, billing }, ...events],
|
||||
prompt: fixture?.prompt, turns_used: result?.turnsUsed, model: result?.model, output,
|
||||
error: [failure === undefined ? '' : String(failure), billingNote].filter(Boolean).join('\n') || undefined,
|
||||
exit_reason: passed ? 'success' : result?.exitReason === 'success' ? 'assertion_failed' : result?.exitReason ?? 'runner_error' });
|
||||
} finally {
|
||||
if (fixture && passed) fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
return evidenceFile!;
|
||||
}
|
||||
|
||||
if (import.meta.main && process.argv[2] === '--fixture') {
|
||||
const root = fs.realpathSync(process.argv[3]!);
|
||||
const config = json(path.join(root, 'fixture.json')) as { repo: string; env: Record<string, string>; invoice: string; head: string; tokens: string[]; draft: string; receipts: string; tmp: string };
|
||||
const action = process.argv[4];
|
||||
const env = { ...process.env, ...config.env };
|
||||
const bin = (name: string, args: string[]) => command(config.repo, env, path.join(ROOT, 'bin', name), args);
|
||||
const receipts = read(config.receipts).trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { action: string; pass: number });
|
||||
const persisted = receipts.filter(row => row.action === 'persist').length;
|
||||
const pass = persisted + 1;
|
||||
const file = (name: string) => path.join(config.tmp, name);
|
||||
const classified = file(`review-classified-p${pass}.json`);
|
||||
if (receipts.some(row => row.action === 'continue' || row.action === 'stop')) throw new Error('Loop decision already taken');
|
||||
let output = '';
|
||||
if (action === 'classify') {
|
||||
const findings = file(`review-findings-p${pass}.json`);
|
||||
if (!fs.existsSync(findings)) throw new Error(`No Step 9.3 result for pass ${pass}`);
|
||||
const args = ['classify', '--base', 'origin/main', '--pass', String(pass), '--policy', 'frozen', '--findings', findings, '--json'];
|
||||
if (pass > 1) args.push('--range-base', config.head);
|
||||
const run = bin('gstack-review-scope', args);
|
||||
fs.writeFileSync(classified, run.stdout, { mode: 0o600 });
|
||||
output = run.stderr.trim() + '\n' + run.stdout.trim();
|
||||
} else if (action === 'defer') {
|
||||
if (!fs.existsSync(classified)) throw new Error(`Classify pass ${pass} first`);
|
||||
const deferred = file('review-deferred.json');
|
||||
const run = bin('gstack-review-scope', ['defer', '--findings', classified, '--out', deferred, '--dry-run']);
|
||||
output = [run.stderr.trim(), run.stdout.trim()].filter(Boolean).join('\n');
|
||||
} else if (action === 'persist') {
|
||||
if (!fs.existsSync(config.draft)) throw new Error('Write the review record first');
|
||||
const token = config.tokens[persisted];
|
||||
if (!token) throw new Error('No REVIEW_START token for this pass');
|
||||
const run = bin('gstack-review-log', [JSON.stringify(json(config.draft)), '--finish', token]);
|
||||
output = [run.stderr.trim(), run.stdout.trim()].filter(Boolean).join('\n') || 'logged';
|
||||
} else if (action === 'commit') {
|
||||
if (persisted < 1) throw new Error('Log this pass (items 5–6) before committing');
|
||||
const add = gitArgvIn(config.repo, ['add', 'invoice.ts'], 10_000, config.env);
|
||||
if (add.status !== 0) throw new Error(add.stderr.toString());
|
||||
const commit = gitArgvIn(config.repo, ['commit', '-qm', 'fix: pre-landing review fixes'], 10_000, config.env);
|
||||
if (commit.status !== 0) throw new Error(commit.stderr.toString() || 'nothing to commit');
|
||||
output = gitArgvIn(config.repo, ['rev-parse', '--short', 'HEAD'], 10_000, config.env).stdout.toString().trim();
|
||||
} else if (action === 'repeat') {
|
||||
if (!receipts.some(row => row.action === 'commit')) throw new Error('Repeat requires the fixes of this pass committed (item 4)');
|
||||
if (receipts.some(row => row.action === 'repeat')) throw new Error('Only one repeat pass is supported');
|
||||
const token = bin('gstack-review-log', ['--start', 'review']).stdout.trim();
|
||||
config.tokens.push(token);
|
||||
write(path.join(root, 'fixture.json'), config);
|
||||
write(file(`review-findings-p${pass}.json`), []);
|
||||
const head = gitArgvIn(config.repo, ['rev-parse', 'HEAD'], 10_000, config.env).stdout.toString().trim();
|
||||
output = JSON.stringify({ pass, range: `${config.head}..${head}`, tests: 'pass', steps: '5–9.3 complete', findings: [], coverage: 'completed',
|
||||
step_9_3_result: `.gstack/tmp/review-findings-p${pass}.json`, review_start_captured: true });
|
||||
} else if (action === 'continue' || action === 'stop') {
|
||||
if (persisted < 1) throw new Error('Decide only after this pass is logged');
|
||||
output = JSON.stringify({ decision: action === 'continue' ? 4 : 'stop', next: action === 'continue' ? 'Step 10' : 'STOP' });
|
||||
} else throw new Error('Unknown fixture action');
|
||||
fs.appendFileSync(config.receipts, JSON.stringify({ action, pass, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
|
||||
console.log(output);
|
||||
}
|
||||
@@ -35,6 +35,20 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts',
|
||||
'test/helpers/e2e-gate.ts', 'test/helpers/eval-budgets.ts'
|
||||
],
|
||||
// The frozen review scope's two-pass convergence: the generated Step 9.4 + repeat decision, the real
|
||||
// gstack-review-scope / gstack-review-log binaries and the ship-policy review_scope key it reads.
|
||||
'ship-frozen-scope-convergence': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'package.json', 'bun.lock', '.github/docker/Dockerfile.ci',
|
||||
'scripts/resolvers/review-scope.ts', 'scripts/resolvers/review-army.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/index.ts',
|
||||
'scripts/resolvers/types.ts', 'scripts/gen-skill-docs.ts', 'scripts/host-config.ts', 'scripts/discover-skills.ts', 'hosts/claude.ts', 'hosts/index.ts', 'hosts/define-host.ts',
|
||||
'ship/sections/review-army.md*', 'ship/sections/manifest.json', 'review/checklist.md',
|
||||
'bin/gstack-review-scope', 'lib/review-scope.ts', 'lib/ship-policy.ts', 'lib/result-codes-release.ts',
|
||||
'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh',
|
||||
'lib/review-evidence.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
'test/helpers/ship-frozen-scope-actor.ts', 'test/helpers/scratch-repo.ts',
|
||||
'test/skill-e2e-ship-frozen-scope.test.ts', 'test/helpers/shared-libs-eval-fixture.ts',
|
||||
'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts',
|
||||
'test/helpers/e2e-gate.ts', 'test/helpers/eval-budgets.ts'
|
||||
],
|
||||
'investigate-owned-completion': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-completion.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'investigate-owned-abort': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
'investigate-owned-ending-error': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
|
||||
@@ -1139,6 +1153,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
*/
|
||||
export const E2E_TIERS: Record<string, 'gate' | 'periodic' | 'marathon'> = {
|
||||
'ship-skipped-queued-finding': 'gate',
|
||||
'ship-frozen-scope-convergence': 'gate',
|
||||
'investigate-owned-completion': 'gate',
|
||||
'investigate-owned-abort': 'gate',
|
||||
'investigate-owned-ending-error': 'gate',
|
||||
@@ -1654,6 +1669,7 @@ export const GLOBAL_TOUCHFILES = [
|
||||
*/
|
||||
export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
|
||||
'ship-skipped-queued-finding': 'rule',
|
||||
'ship-frozen-scope-convergence': 'behavior',
|
||||
'investigate-owned-completion': 'rule',
|
||||
'investigate-owned-abort': 'rule',
|
||||
'investigate-owned-ending-error': 'rule',
|
||||
@@ -1938,6 +1954,8 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
|
||||
* E2E_KINDS; values are non-empty.
|
||||
*/
|
||||
export const BEHAVIOR_WHY: Record<string, string> = {
|
||||
'ship-frozen-scope-convergence':
|
||||
"How the live model words its fix of the in-diff defect and its review rows can vary run to run; never editing the pre-existing file, deferring its blocking finding, reviewing only the range on pass 2 and exiting through decision 4 stay the contract the verdict checks.",
|
||||
'ship-measure-seeded-flake':
|
||||
"How the live model words its classification and fix can vary run to run; measuring before fixing, re-measuring at target and running the gate once after stay assertions, and answering only the spend question is a contract.",
|
||||
'shared-libs-opportunity-judgment':
|
||||
|
||||
@@ -155,14 +155,17 @@ describe('template wiring', () => {
|
||||
const main = source('SKILL.md.tmpl');
|
||||
const step = (title: string, next: string) => main.slice(main.indexOf(title), main.indexOf(next));
|
||||
|
||||
test('Step 2 and the Step 3 CI wait call the helper, not a --required-only query', () => {
|
||||
test('Step 2 calls the gate helper and Step 3 is one gstack-pr-wait call, not a polling loop or a --required-only query', () => {
|
||||
const step2 = step('## Step 2: Pre-merge checks', '## Step 3: Wait for CI');
|
||||
const step3 = step('## Step 3: Wait for CI', '## Step 3.4');
|
||||
expect(step2).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
|
||||
expect(step3).toMatch(/bin\/gstack-ci-gate [^\n]*--expect-head "\$PR_HEAD" --wait \d+/);
|
||||
expect(step3).toContain('bin/gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green');
|
||||
expect(step3.match(/```bash/g)).toHaveLength(1);
|
||||
expect(`${step2}${step3}`).not.toMatch(/gh pr checks[^\n]*--required/);
|
||||
expect(step3).not.toContain('--watch');
|
||||
expect(step3).toContain('still-pending checks');
|
||||
expect(step3).not.toMatch(/--watch|--wait \d+|4-minute|sleep/);
|
||||
for (const code of ['Exit 0', 'PR_WAIT_BLOCKED', 'PR_WAIT_REFUSED']) expect(step3).toContain(code);
|
||||
expect(step3).toMatch(/restart at\s+Step 1/);
|
||||
expect(main).not.toContain('4-minute');
|
||||
expect(step2).toContain('never on the exit code');
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
||||
import {
|
||||
buildPaidShardArgs, buildRunManifest, parseRunManifest, planPaidShards,
|
||||
DEFAULT_JOBS, parseCliOptions, paidShardWallUpperBoundMs, resolvePaidShardBudget, retriesForFiles, verifySliceResults, collectPaidTestFiles, selectPaidTestFiles,
|
||||
shardFile, sliceExecutionOrder, sliceSupervisedWallMs, CASE_SHARDED_FILES, sliceCiTimeoutMinutes, resolvePaidShardTimeoutMs,
|
||||
shardFile, shardCaseId, shardTrial, sliceExecutionOrder, sliceSupervisedWallMs, CASE_SHARDED_FILES, sliceCiTimeoutMinutes, resolvePaidShardTimeoutMs,
|
||||
} from '../scripts/test-paid-shards';
|
||||
import {
|
||||
ALL_TIERS, AUQ_CONSISTENCY_RETRY_BUDGET, FILE_RETRY_BUDGETS,
|
||||
@@ -331,6 +331,7 @@ test('gate census requires all seven distinct slice results and its own reconcil
|
||||
files: [row.file], status: 'passed' as const, exitCode: 0, elapsedMs: 1, skippedTests: 0,
|
||||
executedTests: STRICT_RETRY_CASE_BUDGETS.find(budget => budget.file === row.file)?.cases ?? 1,
|
||||
...(row.budget ? { budget: row.budget } : {}),
|
||||
...(row.trial ? { trial: { case: shardCaseId(row.file)!, trial: shardTrial(row.file)!, ...row.trial, outcome: 'passed' as const, cost_usd: 0, duration_ms: 1 } } : {}),
|
||||
})),
|
||||
}));
|
||||
expect(verifySliceResults(manifest, results)).toEqual({ ok: true, problems: [] });
|
||||
|
||||
@@ -532,9 +532,15 @@ describe('trial planner (behavior and quarantined panels)', () => {
|
||||
const manifest = budgetPlan('gate', { 'review-sql-injection': 'behavior' });
|
||||
const lines = formatCapacityPreflight(manifest, 16).join('\n');
|
||||
expect(lines).toContain(`${manifest.sliceCount} slice(s)`);
|
||||
expect(lines).toContain('3 trial shard(s)');
|
||||
// Every planned trial shard belongs to a 3-trial panel: the overridden review case plus every registered gate behavior case.
|
||||
const trials = manifest.entries.filter(e => e.status === 'planned' && e.trial);
|
||||
const panels = new Set(trials.map(e => e.file.replace(/~t\d+$/, '')));
|
||||
expect(trials.length).toBe(3 * panels.size);
|
||||
expect([...panels]).toContain('test/skill-e2e-review.test.ts#review-sql-injection');
|
||||
expect(lines).toContain(`${trials.length} trial shard(s) in ${panels.size} panel(s)`);
|
||||
expect(lines).toContain(`wave(s) at max-parallel 16: ${Math.ceil(manifest.sliceCount / 16)}`);
|
||||
expect(lines).toMatch(/longest indivisible trial ~\d+\.\dm \(test\/skill-e2e-review\.test\.ts#review-sql-injection~t\d\)/);
|
||||
const longest = [...trials].sort((a, b) => (b.estimatedMs ?? 0) - (a.estimatedMs ?? 0))[0]!;
|
||||
expect(lines).toMatch(new RegExp(`longest indivisible trial ~\\d+\\.\\dm \\(${longest.file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
|
||||
});
|
||||
|
||||
test('durations: trials record their longest wall under the case key and seed their own estimate', () => {
|
||||
|
||||
@@ -4,18 +4,21 @@ import * as path from 'path';
|
||||
|
||||
// D2: census 37195203538 refused plan-ceo-review-format-approach twice
|
||||
// (reasoning_extraction, "duplicating model outputs") on its session prompt.
|
||||
// GSTA-96: the mode menu refused at the same capture Write twice in three trials.
|
||||
const source = fs.readFileSync(path.join(import.meta.dir, 'skill-e2e-plan-format.test.ts'), 'utf8');
|
||||
const start = source.indexOf("testConcurrentIfSelected('plan-ceo-review-format-approach'");
|
||||
const block = source.slice(start, source.indexOf('validate:', start));
|
||||
const instruction = /function approachQuestionInstruction\(outFile: string\): string \{\n\s*return `([^`]+)`;/.exec(source)?.[1] ?? '';
|
||||
|
||||
test('the approach-menu prompt delivers its question without output-harvesting wording', () => {
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(block).toContain('${approachQuestionInstruction(outFile)}');
|
||||
expect(block).not.toContain('captureInstruction(');
|
||||
const prompt = block + instruction;
|
||||
for (const phrase of [/verbatim/i, /paraphrase/i, /exact prose/i, /format-capture/i]) expect(prompt).not.toMatch(phrase);
|
||||
expect(instruction).toContain('${outFile}');
|
||||
expect(instruction).toContain('do not call any tool to ask the user');
|
||||
expect(instruction).toContain('every option, and the Recommendation line');
|
||||
});
|
||||
for (const caseId of ['plan-ceo-review-format-approach', 'plan-ceo-review-format-mode']) {
|
||||
test(`${caseId} delivers its question without output-harvesting wording`, () => {
|
||||
const start = source.indexOf(`testConcurrentIfSelected('${caseId}'`);
|
||||
const block = source.slice(start, source.indexOf('validate:', start));
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(block).toContain('${approachQuestionInstruction(outFile)}');
|
||||
expect(block).not.toContain('captureInstruction(');
|
||||
const prompt = block + instruction;
|
||||
for (const phrase of [/verbatim/i, /paraphrase/i, /exact prose/i, /format-capture/i]) expect(prompt).not.toMatch(phrase);
|
||||
expect(instruction).toContain('${outFile}');
|
||||
expect(instruction).toContain('do not call any tool to ask the user');
|
||||
expect(instruction).toContain('every option, and the Recommendation line');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* gstack-pr-wait (lib/pr-wait.ts, lib/pr-wait-github.ts): one stdout line per
|
||||
* real event, state under the gstack state root, required checks from rulesets
|
||||
* so an unstarted one is pending, two identical polls before green, the
|
||||
* base-moved-and-touches-this-PR event, BEHIND and DIRTY, the per-PR lock, the
|
||||
* 6-hour baseline staleness and the configurable bot lists. Every GitHub read
|
||||
* goes through a fake gh runner, as test/gstack-ci-gate.test.ts does.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { GhResult } from '../lib/ci-gate';
|
||||
import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
|
||||
import { GhError, runGh } from '../lib/pr-wait-github';
|
||||
import { RESULT_CODES } from '../lib/result-codes';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const HEAD = 'a'.repeat(40);
|
||||
const BASE_A = 'b'.repeat(40);
|
||||
const BASE_B = 'c'.repeat(40);
|
||||
const REPO = 'owner/project';
|
||||
|
||||
const ok = (stdout: string): GhResult => ({ status: 0, stdout, stderr: '' });
|
||||
const err = (stderr: string, status = 1): GhResult => ({ status, stdout: '', stderr: `${stderr}\n` });
|
||||
const NOT_FOUND = err('gh: Not Found (HTTP 404)');
|
||||
|
||||
interface Author { login: string; bot?: boolean }
|
||||
const actor = ({ login, bot }: Author) => ({ login, __typename: bot ? 'Bot' : 'User' });
|
||||
const comment = (id: string, author: Author, body: string) => ({ id, url: `https://github.com/${REPO}/pull/42#issuecomment-${id}`, body, author: actor(author) });
|
||||
const review = (id: string, author: Author, state: string, body = '', commit: string | null = HEAD) =>
|
||||
({ id, url: `https://github.com/${REPO}/pull/42#pullrequestreview-${id}`, state, body, commit: commit ? { oid: commit } : null, author: actor(author) });
|
||||
const thread = (path: string, line: number | null, resolved: boolean, ...comments: ReturnType<typeof comment>[]) => ({ isResolved: resolved, path, line, comments: { nodes: comments } });
|
||||
|
||||
interface PrShape {
|
||||
state?: string; head?: string; base?: string; baseOid?: string | null; mergeState?: string;
|
||||
comments?: ReturnType<typeof comment>[]; reviews?: ReturnType<typeof review>[]; threads?: ReturnType<typeof thread>[];
|
||||
}
|
||||
const prJson = (shape: PrShape = {}) => JSON.stringify({
|
||||
data: {
|
||||
viewer: { login: 'me' },
|
||||
repository: {
|
||||
pullRequest: {
|
||||
url: `https://github.com/${REPO}/pull/42`, state: shape.state ?? 'OPEN', headRefOid: shape.head ?? HEAD,
|
||||
baseRefName: shape.base ?? 'main', mergeStateStatus: shape.mergeState ?? 'CLEAN', author: { login: 'author' },
|
||||
baseRef: shape.baseOid === null ? null : { target: { oid: shape.baseOid ?? BASE_A } },
|
||||
comments: { nodes: shape.comments ?? [] }, reviews: { nodes: shape.reviews ?? [] }, reviewThreads: { nodes: shape.threads ?? [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const checksJson = (...rows: Array<[string, string] | [string, string, string]>) =>
|
||||
JSON.stringify(rows.map(([name, bucket, description]) => ({ name, bucket, link: `https://ci.example/${name}`, description: description ?? '' })));
|
||||
|
||||
/** Scripted replies per gh command shape; a list is consumed one poll at a time, the last reply repeating. */
|
||||
interface Script {
|
||||
pr?: string | string[];
|
||||
checks?: GhResult | GhResult[];
|
||||
rulesets?: GhResult;
|
||||
protection?: GhResult;
|
||||
prFiles?: GhResult;
|
||||
compare?: Record<string, GhResult>;
|
||||
behind?: GhResult;
|
||||
}
|
||||
function fakeGh(script: Script) {
|
||||
// Function declarations, not generic arrows: pregate's import scanner reads tests as TSX, where `<T>(x) =>` is a tag.
|
||||
function queue<T>(value: T | T[] | undefined): T[] { return value === undefined ? [] : Array.isArray(value) ? [...value] : [value]; }
|
||||
function next<T>(q: T[]): T | undefined { return q.length > 1 ? q.shift() : q[0]; }
|
||||
const prs = queue(script.pr).map(ok);
|
||||
const checks = queue(script.checks);
|
||||
const calls: string[][] = [];
|
||||
const gh = (args: string[]): GhResult => {
|
||||
calls.push(args);
|
||||
const [a, b] = args;
|
||||
if (a === 'api' && b === 'graphql') return next(prs) ?? ok(prJson());
|
||||
if (a === 'pr' && b === 'checks') return next(checks) ?? ok(checksJson());
|
||||
if (a === 'pr' && b === 'view') return ok('{"number":42}');
|
||||
if (a === 'pr' && b === 'diff') return script.prFiles ?? ok('');
|
||||
if (a === 'repo' && b === 'view') return ok(`${REPO}\n`);
|
||||
if (a === 'api' && b.includes('/rules/branches/')) return script.rulesets ?? NOT_FOUND;
|
||||
if (a === 'api' && b.includes('/protection/')) return script.protection ?? NOT_FOUND;
|
||||
if (a === 'api' && b.includes('/compare/')) {
|
||||
if (args.includes('.behind_by')) return script.behind ?? ok('0\n');
|
||||
return script.compare?.[b.split('/compare/')[1]] ?? ok('');
|
||||
}
|
||||
throw new Error(`fake gh: unexpected ${args.join(' ')}`);
|
||||
};
|
||||
return Object.assign(gh, { calls });
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let stdout: string[];
|
||||
let stderr: string[];
|
||||
let clock: number;
|
||||
let alive: Set<number>;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'pr-wait-'));
|
||||
stdout = [];
|
||||
stderr = [];
|
||||
clock = 1_700_000_000_000;
|
||||
alive = new Set([4242]);
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
function env(gh: PrWaitEnv['gh'], pid = 4242, vars: Record<string, string> = {}): PrWaitEnv {
|
||||
return {
|
||||
gh, vars: { HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state'), ...vars }, pid,
|
||||
now: () => clock, sleep: () => {}, out: t => stdout.push(t), err: t => stderr.push(t), pidAlive: p => alive.has(p),
|
||||
};
|
||||
}
|
||||
const watcher = (script: Script, until: PrWaitOptions['until'] = 'ready', vars: Record<string, string> = {}) => {
|
||||
const e = env(fakeGh(script), 4242, vars);
|
||||
return new Watcher(e, REPO, 42, until, 20, botLists(e.vars));
|
||||
};
|
||||
/** Run polls until one returns a code or the budget is spent; the first poll is the baseline. */
|
||||
function pollUntilDone(w: Watcher, budget = 6): number | null {
|
||||
for (let i = 0; i < budget; i++) {
|
||||
const code = w.poll();
|
||||
if (code !== null) return code;
|
||||
w.save();
|
||||
w.baseline = false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const lines = () => stdout.join('').split('\n').filter(Boolean);
|
||||
const RULESETS = ok('free-tests\nwindows-free-tests\n');
|
||||
|
||||
describe('state lives under the gstack state root', () => {
|
||||
test('state file and lock are <root>/pr-wait/<owner>__<name>__<pr>, never ~/.local/state', () => {
|
||||
const w = watcher({});
|
||||
expect(w.stateFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.json'));
|
||||
expect(w.lockFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.lock'));
|
||||
expect(stateDir({ HOME: dir })).toBe(join(dir, '.gstack', 'pr-wait'));
|
||||
pollUntilDone(w, 1);
|
||||
expect(existsSync(w.stateFile)).toBe(true);
|
||||
expect(existsSync(join(dir, '.local'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a state file older than six hours is a fresh baseline: nothing from it is replayed and nothing new is reported on that poll', () => {
|
||||
const file = join(dir, 'state', 'pr-wait', 'owner__project__42.json');
|
||||
mkdirSync(join(dir, 'state', 'pr-wait'), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify({ reported: [], summaries: {}, base_oid: BASE_A, merge_state: 'CLEAN' }));
|
||||
const old = new Date(clock - 7 * 3600 * 1000);
|
||||
utimesSync(file, old, old);
|
||||
const w = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
|
||||
expect(w.baseline).toBe(true);
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const fresh = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
|
||||
expect(fresh.baseline).toBe(true);
|
||||
});
|
||||
|
||||
test('a state file within six hours carries the baseline across a restart, so only what arrived meanwhile prints', () => {
|
||||
const first = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first')] }) });
|
||||
pollUntilDone(first, 1);
|
||||
expect(lines()).toEqual([]);
|
||||
clock += 60 * 60 * 1000;
|
||||
const restarted = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first'), comment('c2', { login: 'reviewer' }, 'second thought')] }) });
|
||||
expect(restarted.baseline).toBe(false);
|
||||
restarted.poll();
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 comment from reviewer: second thought https:/)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('events: one stdout line each, progress on stderr', () => {
|
||||
test('the baseline poll records what exists and prints nothing; the next poll prints only new comments, reviews and thread replies, never the author, the gh user, or edits', () => {
|
||||
const before = prJson({ comments: [comment('c1', { login: 'reviewer' }, 'old')] });
|
||||
const after = prJson({
|
||||
comments: [comment('c1', { login: 'reviewer' }, 'old, edited'), comment('c2', { login: 'author' }, 'mine'), comment('c3', { login: 'me' }, 'also mine'),
|
||||
comment('c4', { login: 'github-actions', bot: true }, 'deploy preview ready'), comment('c5', { login: 'reviewer' }, '<b>Please</b> & fix <this>')],
|
||||
reviews: [review('r1', { login: 'reviewer' }, 'CHANGES_REQUESTED', 'nope'), review('r2', { login: 'lurker' }, 'COMMENTED', '')],
|
||||
threads: [thread('lib/x.ts', 12, false, comment('t1', { login: 'reviewer' }, 'rename this'), comment('t2', { login: 'author' }, 'done'))],
|
||||
});
|
||||
const w = watcher({ pr: [before, after] }, 'any-change');
|
||||
expect(w.run()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([
|
||||
'#42 comment from reviewer: Please & fix <this> https://github.com/owner/project/pull/42#issuecomment-c5',
|
||||
'#42 review from reviewer (changes requested): nope https://github.com/owner/project/pull/42#pullrequestreview-r1',
|
||||
'#42 new thread from reviewer on lib/x.ts:12: rename this https://github.com/owner/project/pull/42#issuecomment-t1',
|
||||
]);
|
||||
expect(stderr.join('')).toContain('watching owner/project#42 until any-change; required checks: none set; every check on the head gates');
|
||||
expect(stderr.join('')).toContain('baseline recorded for owner/project#42');
|
||||
});
|
||||
|
||||
test('merged ends with exit 0; closed without merging is PR_WAIT_BLOCKED', () => {
|
||||
expect(pollUntilDone(watcher({ pr: prJson({ state: 'MERGED' }) }))).toBe(0);
|
||||
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
|
||||
stdout = [];
|
||||
expect(pollUntilDone(watcher({ pr: prJson({ state: 'CLOSED' }) }))).toBe(1);
|
||||
expect(lines()).toEqual(['#42 closed without merging: https://github.com/owner/project/pull/42', expect.stringContaining('stopped: action needed (PR_WAIT_BLOCKED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pr-wait-blocked)')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required checks and the green condition', () => {
|
||||
test('a required check named by the ruleset that has not started is pending, not green by omission', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'])) }, 'green');
|
||||
expect(pollUntilDone(w, 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(stderr.join('')).toContain('required checks: free-tests, windows-free-tests');
|
||||
});
|
||||
|
||||
test('a required check that failed ends the watch at once, naming it with its link, even on the baseline poll', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pending'])) }, 'green');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('an unknown bucket counts as failed, as gstack-ci-gate counts it', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'stale'], ['windows-free-tests', 'pass'])) }, 'green');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
|
||||
});
|
||||
|
||||
test('green needs two identical polls: a check set seen once is not stable', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: [ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'])), ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['late-check', 'pass']))], behind: ok('2\n') }, 'green');
|
||||
expect(w.poll()).toBeNull();
|
||||
w.baseline = false;
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 green on aaaaaaa: 3 pass; required 2/2 green; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN; 2 commits behind main']);
|
||||
});
|
||||
|
||||
test('every check on the head gates, advisory bots\' own included, and a gating bot\'s review on the head makes it ready', () => {
|
||||
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', 'Confidence score: 4/5')] });
|
||||
const held = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pending', 'Review in progress'])) });
|
||||
expect(pollUntilDone(held, 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pass', 'Reviewed'])) });
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 coderabbit finished on aaaaaaa (advisory, not waited on)', '#42 ready on aaaaaaa: 3 pass; greptile done, coderabbit Reviewed (advisory); 0 unresolved threads; merge state CLEAN; 0 commits behind main']);
|
||||
});
|
||||
|
||||
test('ready is blocked by unresolved threads and by BEHIND, in that order', () => {
|
||||
const base = { checks: ok(checksJson(['build', 'pass'])), rulesets: NOT_FOUND };
|
||||
const reviewed = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
|
||||
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], threads: [thread('a.ts', 1, false, comment('t1', { login: 'reviewer' }, 'hm'))] }) }))).toBe(1);
|
||||
expect(lines()[0]).toBe('#42 blocked by 1 unresolved threads on aaaaaaa: https://github.com/owner/project/pull/42#issuecomment-t1');
|
||||
stdout = [];
|
||||
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], mergeState: 'BEHIND' }) }))).toBe(1);
|
||||
expect(lines()).toContain('#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging');
|
||||
});
|
||||
});
|
||||
|
||||
describe('base branch and merge state', () => {
|
||||
const compareKey = `${BASE_A}...${BASE_B}`;
|
||||
|
||||
test('the base moved and its new commits touch files in this PR → one event naming the overlap', () => {
|
||||
const w = watcher({
|
||||
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
|
||||
prFiles: ok('lib/pr-wait.ts\nREADME.md\n'),
|
||||
compare: { [compareKey]: ok('lib/pr-wait.ts\nCHANGELOG.md\n') },
|
||||
}, 'any-change');
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: lib/pr-wait.ts']);
|
||||
});
|
||||
|
||||
test('the base moved without touching this PR → silence, and the new base oid is remembered', () => {
|
||||
const w = watcher({
|
||||
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
|
||||
prFiles: ok('lib/pr-wait.ts\n'),
|
||||
compare: { [compareKey]: ok('CHANGELOG.md\n') },
|
||||
}, 'any-change');
|
||||
expect(pollUntilDone(w, 3)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(JSON.parse(readFileSync(w.stateFile, 'utf8')).base_oid).toBe(BASE_B);
|
||||
});
|
||||
|
||||
test('a restarted watcher reports a base move that happened while no watcher ran', () => {
|
||||
const first = watcher({ pr: prJson({ baseOid: BASE_A }) });
|
||||
first.poll();
|
||||
first.save();
|
||||
const restarted = watcher({ pr: prJson({ baseOid: BASE_B }), prFiles: ok('x\n'), compare: { [compareKey]: ok('x\n') } }, 'any-change');
|
||||
expect(restarted.baseline).toBe(false);
|
||||
expect(restarted.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: x']);
|
||||
});
|
||||
|
||||
test('BEHIND is an event once per head, again after a push that stays BEHIND; DIRTY ends the watch as blocked', () => {
|
||||
const D = 'd'.repeat(40);
|
||||
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'UNKNOWN', head: D }), prJson({ mergeState: 'BEHIND', head: D })], checks: ok(checksJson(['build', 'pending'])) }, 'green');
|
||||
expect(pollUntilDone(behind, 5)).toBeNull();
|
||||
expect(lines()).toEqual([
|
||||
'#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging',
|
||||
'#42 merge state is BEHIND on ddddddd: update the branch from main before merging',
|
||||
]);
|
||||
stdout = [];
|
||||
const dirty = watcher({ pr: prJson({ mergeState: 'DIRTY' }) }, 'green');
|
||||
expect(dirty.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review bots are configuration', () => {
|
||||
const withConfig = (yaml: string) => {
|
||||
mkdirSync(join(dir, 'state'), { recursive: true });
|
||||
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
|
||||
};
|
||||
|
||||
test('defaults are tstack\'s: greptile and macroscope gate, coderabbit is advisory', () => {
|
||||
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: ['coderabbit'] });
|
||||
});
|
||||
|
||||
test('pr_wait_gating_bots decides which bot\'s review makes the PR ready', () => {
|
||||
withConfig('pr_wait_gating_bots: macroscope\n');
|
||||
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
|
||||
const w = watcher({ pr, checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] });
|
||||
expect(w.bots).toEqual({ gating: ['macroscope'], advisory: ['coderabbit'] });
|
||||
expect(pollUntilDone(w, 4)).toBeNull();
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
|
||||
});
|
||||
|
||||
test('none empties a list', () => {
|
||||
withConfig('pr_wait_advisory_bots: none\n');
|
||||
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: [] });
|
||||
});
|
||||
|
||||
test('pr_wait_advisory_bots: its review is reported but never waited on, while its check gates like any other', () => {
|
||||
withConfig('pr_wait_gating_bots: greptile\npr_wait_advisory_bots: coderabbit, Sourcery\n');
|
||||
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
|
||||
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['sourcery-ai', 'pass', 'Reviewed'])) });
|
||||
expect(w.bots.advisory).toEqual(['coderabbit', 'sourcery']);
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()[0]).toContain('ready on aaaaaaa: 2 pass; greptile done, sourcery Reviewed (advisory)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the per-PR lock', () => {
|
||||
test('a live holder refuses the second watcher with PR_WAIT_REFUSED; a dead holder is taken over and released on exit', () => {
|
||||
const first = watcher({});
|
||||
expect(first.lock()).toBeNull();
|
||||
const second = new Watcher(env(fakeGh({}), 4343), REPO, 42, 'ready', 20, botLists({}));
|
||||
expect(second.lock()).toEqual({ held: '4242' });
|
||||
alive.clear();
|
||||
expect(second.lock()).toBeNull();
|
||||
expect(readFileSync(second.lockFile, 'utf8')).toBe('4343');
|
||||
first.unlock();
|
||||
expect(existsSync(second.lockFile)).toBe(true);
|
||||
second.unlock();
|
||||
expect(existsSync(second.lockFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('prWaitMain refuses a duplicate watcher before any poll', () => {
|
||||
const w = watcher({});
|
||||
expect(w.lock()).toBeNull();
|
||||
const gh = fakeGh({});
|
||||
const code = prWaitMain(['42', '--repo', REPO], env(gh, 9999));
|
||||
expect(code).toBe(PR_WAIT_EXIT.refused);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^gstack-pr-wait: the watcher did not start, or a local error stopped it before a verdict — pid 4242 already watches owner\/project#42.*\(PR_WAIT_REFUSED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pr-wait-refused\)$/)]);
|
||||
expect(gh.calls.some(c => c[1] === 'graphql')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arguments and the gh layer', () => {
|
||||
test('targets: number, #number, URL (repo from the URL), --repo override; bad input is refused', () => {
|
||||
expect(parsePrTarget('42')).toEqual({ repo: undefined, pr: 42 });
|
||||
expect(parsePrTarget('#42', REPO)).toEqual({ repo: REPO, pr: 42 });
|
||||
expect(parsePrTarget('https://github.com/garrytan/gstack/pull/3481/files')).toEqual({ repo: 'garrytan/gstack', pr: 3481 });
|
||||
expect(parsePrTarget('')).toMatchObject({ error: expect.stringContaining('missing PR number') });
|
||||
expect(parsePrTarget('abc')).toMatchObject({ error: "'abc' is not a PR number or PR URL" });
|
||||
expect(parsePrTarget('0')).toMatchObject({ error: "'0' is not a valid PR number" });
|
||||
expect(parsePrTarget('42', 'nope')).toMatchObject({ error: "'nope' is not owner/name" });
|
||||
expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60 });
|
||||
expect(parsePrWaitArgs(['42', '--timeout', '0'])).toMatchObject({ timeout: 0 });
|
||||
expect(parsePrWaitArgs(['42', '--timeout', 'soon'])).toMatchObject({ error: expect.stringContaining('--timeout needs') });
|
||||
expect(parsePrWaitArgs(['--until', 'soon'])).toMatchObject({ error: expect.stringContaining('--until needs') });
|
||||
expect(parsePrWaitArgs(['--bogus'])).toMatchObject({ error: 'unknown argument --bogus' });
|
||||
expect(parsePrWaitArgs(['--help'])).toEqual({ help: true });
|
||||
});
|
||||
|
||||
test('main: --help exits 0, bad input exits 2 with the code and anchor, an unreadable PR exits 2', () => {
|
||||
expect(prWaitMain(['--help'], env(fakeGh({})))).toBe(0);
|
||||
expect(stdout.join('')).toContain('--until');
|
||||
stdout = [];
|
||||
expect(prWaitMain([], env(fakeGh({})))).toBe(2);
|
||||
expect(lines()[0]).toMatch(/missing PR number.*\(PR_WAIT_REFUSED; .*#pr-wait-refused\)$/);
|
||||
stdout = [];
|
||||
const gone = (args: string[]): GhResult => (args[1] === 'view' ? err('GraphQL: Could not resolve to a PullRequest with the number of 42. (HTTP 404)') : ok(''));
|
||||
expect(prWaitMain(['42', '--repo', REPO], env(gone))).toBe(2);
|
||||
expect(lines()[0]).toContain('cannot read owner/project#42: GraphQL: Could not resolve');
|
||||
expect(RESULT_CODES.PR_WAIT_REFUSED.anchor).toBe('pr-wait-refused');
|
||||
expect(RESULT_CODES.PR_WAIT_BLOCKED.anchor).toBe('pr-wait-blocked');
|
||||
});
|
||||
|
||||
test('a transient gh failure is retried with backoff and never ends the watch; a 403 on classic protection is logged, not fatal', () => {
|
||||
let graphqlCalls = 0;
|
||||
const base = fakeGh({ checks: ok(checksJson(['build', 'pass'])), protection: err('gh: Resource not accessible by integration (HTTP 403)') });
|
||||
const flaky = (args: string[]): GhResult => {
|
||||
if (args[1] === 'graphql' && graphqlCalls++ === 0) return err('gh: Something went wrong (HTTP 502)');
|
||||
return base(args);
|
||||
};
|
||||
const e = env(flaky);
|
||||
const w = new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars));
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(graphqlCalls).toBe(2);
|
||||
expect(stderr.join('')).toContain('gh api graphql: gh: Something went wrong (HTTP 502); retry in 2s');
|
||||
expect(stderr.join('')).toContain('could not read required checks from repos/owner/project/branches/main/protection/required_status_checks: gh: Resource not accessible by integration (HTTP 403)');
|
||||
});
|
||||
|
||||
test('the bin runs on the isolated bun shebang and prints the usage', () => {
|
||||
const result = spawnSync(join(ROOT, 'bin', 'gstack-pr-wait'), ['--help'], { encoding: 'utf8', cwd: ROOT, timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('gstack-config set pr_wait_gating_bots greptile,macroscope');
|
||||
expect(readFileSync(join(ROOT, 'bin', 'gstack-pr-wait'), 'utf8').startsWith('#!/usr/bin/env -S bun --no-env-file --config=/dev/null\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pass-1 review fixes: gates agree, bounded waits, bot identity, local faults', () => {
|
||||
function withConfig(yaml: string) {
|
||||
mkdirSync(join(dir, 'state'), { recursive: true });
|
||||
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
|
||||
}
|
||||
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
|
||||
|
||||
test('green gates on every check on the head, as gstack-ci-gate does: a pending non-required check holds it and a red one blocks it', () => {
|
||||
const pending = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'pending'])) }, 'green');
|
||||
expect(pollUntilDone(pending, 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const red = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'green');
|
||||
expect(red.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 check failed: quality-gate https://ci.example/quality-gate', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('a failed bot check is a failed check, gating or advisory, as gstack-ci-gate counts it; never a completed review', () => {
|
||||
const gating = watcher({ pr: prJson({ reviews: [] }), checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'fail'])) });
|
||||
expect(pollUntilDone(gating)).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 check failed: greptile-review https://ci.example/greptile-review');
|
||||
stdout = [];
|
||||
const advisory = watcher({ pr: prJson({ reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'], ['coderabbit', 'fail'])) });
|
||||
expect(pollUntilDone(advisory)).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual([
|
||||
'#42 check failed: coderabbit https://ci.example/coderabbit',
|
||||
'#42 greptile finished on aaaaaaa',
|
||||
'#42 coderabbit finished on aaaaaaa: failed: coderabbit (advisory, not waited on)',
|
||||
expect.stringContaining('(PR_WAIT_BLOCKED;'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('BEHIND is keyed on the head alone: a base that keeps moving does not repeat the line, even across restarts', () => {
|
||||
const first = watcher({ pr: [prJson({ mergeState: 'BEHIND', baseOid: BASE_A }), prJson({ mergeState: 'BEHIND', baseOid: BASE_B })], checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: { [`${BASE_A}...${BASE_B}`]: ok('') } }, 'any-change');
|
||||
expect(pollUntilDone(first, 3)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const restarted = watcher({ pr: prJson({ mergeState: 'BEHIND', baseOid: 'e'.repeat(40) }), checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: {} }, 'any-change');
|
||||
expect(restarted.baseline).toBe(false);
|
||||
expect(pollUntilDone(restarted, 3)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
});
|
||||
|
||||
test('a verdict keeps its exit code when the state file cannot be written; the save error goes to stderr', () => {
|
||||
const w = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
|
||||
rmSync(join(dir, 'state'), { recursive: true, force: true });
|
||||
writeFileSync(join(dir, 'state'), '');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
|
||||
expect(stderr.join('')).toContain('could not save state');
|
||||
});
|
||||
|
||||
test('a bot owns logins that start with its name, so a person called notgreptile is a reviewer, not the gating bot', () => {
|
||||
const human = review('h1', { login: 'notgreptile' }, 'COMMENTED', 'looks fine');
|
||||
const w = watcher({ pr: [prJson({}), prJson({ reviews: [human] })], checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(pollUntilDone(w, 4)).toBeNull();
|
||||
expect(lines()).toEqual(['#42 review from notgreptile (commented): looks fine https://github.com/owner/project/pull/42#pullrequestreview-h1']);
|
||||
});
|
||||
|
||||
test('ready with no gating bot configured is green plus the thread and merge-state checks', () => {
|
||||
withConfig('pr_wait_gating_bots: none\n');
|
||||
const w = watcher({ checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(w.bots.gating).toEqual([]);
|
||||
expect(pollUntilDone(w, 4)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()[0]).toContain('ready on aaaaaaa: 1 pass; no review bot yet');
|
||||
});
|
||||
|
||||
test('ready waits while the merge state is UNKNOWN and is met once GitHub knows it', () => {
|
||||
const w = watcher({ pr: [prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'CLEAN', reviews: [GREPTILE] })], checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(w.poll()).toBeNull();
|
||||
w.baseline = false;
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()[0]).toContain('merge state CLEAN');
|
||||
});
|
||||
|
||||
test('--timeout ends a watch that met nothing as PR_WAIT_BLOCKED, naming what is still pending; 0 waits without limit', () => {
|
||||
function timed(timeout: number) {
|
||||
const e = env(fakeGh({ checks: ok(checksJson(['build', 'pending'], ['docs', 'pass'])) }));
|
||||
e.sleep = ms => { clock += ms; };
|
||||
return new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars), timeout);
|
||||
}
|
||||
expect(timed(1).run()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 gstack-pr-wait stopped: no green after 1 minutes; still pending: build', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
stdout = [];
|
||||
const forever = timed(0);
|
||||
let polls = 0;
|
||||
const poll = forever.poll.bind(forever);
|
||||
forever.poll = () => { polls += 1; return polls < 50 ? poll() : PR_WAIT_EXIT.met; };
|
||||
expect(forever.run()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(polls).toBe(50);
|
||||
});
|
||||
|
||||
test('run() ends as blocked after ten transient failures, and at once on a non-transient gh error', () => {
|
||||
const down = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Something went wrong (HTTP 502)') : ok(''));
|
||||
const e = env(down);
|
||||
expect(new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(stderr.join('').match(/poll failed \(\d+\/10\)/g)).toHaveLength(10);
|
||||
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: GitHub unreachable for 10 polls: gh: Something went wrong (HTTP 502)');
|
||||
stdout = [];
|
||||
const gone = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Not Found (HTTP 404)') : ok(''));
|
||||
const e2 = env(gone);
|
||||
expect(new Watcher(e2, REPO, 42, 'green', 20, botLists(e2.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: gh: Not Found (HTTP 404)');
|
||||
});
|
||||
|
||||
test('a gh spawn timeout is retried like any transient failure; a missing gh is fatal', () => {
|
||||
const results: GhResult[] = [{ status: null, stdout: '', stderr: '', error: 'gh failed to run (ETIMEDOUT)' }, ok('fine')];
|
||||
const slept: number[] = [];
|
||||
const ghEnv = { gh: () => results.shift()!, sleep: (ms: number) => slept.push(ms), log: () => {} };
|
||||
expect(runGh(ghEnv, ['api', 'x']).stdout).toBe('fine');
|
||||
expect(slept).toEqual([2000]);
|
||||
const missing = { gh: (): GhResult => ({ status: null, stdout: '', stderr: '', error: 'gh is not installed or not on PATH' }), sleep: () => {}, log: () => {} };
|
||||
expect(() => runGh(missing, ['api', 'x'])).toThrow(GhError);
|
||||
});
|
||||
|
||||
test('a local fault is a refusal (exit 2 with PR_WAIT_REFUSED), so exit 1 always carries a PR_WAIT_BLOCKED line', () => {
|
||||
writeFileSync(join(dir, 'not-a-dir'), '');
|
||||
const e = env(fakeGh({}), 4242, { GSTACK_STATE_ROOT: join(dir, 'not-a-dir') });
|
||||
expect(prWaitMain(['42', '--repo', REPO], e)).toBe(PR_WAIT_EXIT.refused);
|
||||
expect(lines()[0]).toMatch(/a local error stopped the watch before a verdict: .*(ENOTDIR|EEXIST|not a directory).*\(PR_WAIT_REFUSED;/);
|
||||
});
|
||||
|
||||
test('control characters in GitHub text never start a second event line', () => {
|
||||
const w = watcher({ checks: ok(checksJson(['build\n#42 forged event', 'fail'])) }, 'green');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 check failed: build #42 forged event https://ci.example/build #42 forged event');
|
||||
expect(lines()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('gstack-config validates the bot lists at set time and its defaults match the lib constants', () => {
|
||||
const run = (...args: string[]) => spawnSync(join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', cwd: ROOT, timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: join(dir, 'state'), HOME: dir } });
|
||||
for (const bad of ['Greptile', 'a, b', 'a,', '']) {
|
||||
const r = run('set', 'pr_wait_gating_bots', bad);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('Existing value left unchanged');
|
||||
}
|
||||
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe(DEFAULT_GATING_BOTS.join(','));
|
||||
expect(run('get', 'pr_wait_advisory_bots').stdout.trim()).toBe(DEFAULT_ADVISORY_BOTS.join(','));
|
||||
expect(run('set', 'pr_wait_advisory_bots', 'none').status).toBe(0);
|
||||
expect(run('set', 'pr_wait_gating_bots', 'greptile,macroscope.v2').status).toBe(0);
|
||||
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe('greptile,macroscope.v2');
|
||||
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope.v2'], advisory: [] });
|
||||
});
|
||||
|
||||
test('the bin dies on SIGTERM at once (no JS handler swallows it) and the next watcher takes over its lock', async () => {
|
||||
const fakeBin = join(dir, 'fakebin');
|
||||
mkdirSync(fakeBin);
|
||||
writeFileSync(join(fakeBin, 'gh'), `#!/bin/sh
|
||||
case "$1 $2" in
|
||||
'pr view') echo '{"number":42}' ;;
|
||||
'api graphql') echo '${prJson({})}' ;;
|
||||
'pr checks') echo '${checksJson(['build', 'pending'])}'; exit 8 ;;
|
||||
*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;;
|
||||
esac`, { mode: 0o755 });
|
||||
const stateRoot = join(dir, 'state');
|
||||
const child = spawn(join(ROOT, 'bin', 'gstack-pr-wait'), ['42', '--repo', REPO, '--interval', '5'], { cwd: ROOT, env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, GSTACK_STATE_ROOT: stateRoot, HOME: dir } });
|
||||
const lockFile = join(stateRoot, 'pr-wait', 'owner__project__42.lock');
|
||||
const started = Date.now();
|
||||
while (!existsSync(lockFile) && Date.now() - started < 15_000) await Bun.sleep(100);
|
||||
expect(existsSync(lockFile)).toBe(true);
|
||||
child.kill('SIGTERM');
|
||||
const ended = await Promise.race([
|
||||
new Promise<string>(resolve => child.on('exit', (code, signal) => resolve(`exit ${code} ${signal}`))),
|
||||
Bun.sleep(5_000).then(() => 'alive'),
|
||||
]);
|
||||
expect(ended).toBe('exit null SIGTERM');
|
||||
expect(readFileSync(lockFile, 'utf8')).toBe(String(child.pid));
|
||||
const next = watcher({});
|
||||
expect(next.lock()).toBeNull();
|
||||
expect(readFileSync(lockFile, 'utf8')).toBe('4242');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('state matrix cells (gstack-state-matrix: --until × bucket, merge state × --until, lock, baseline × event)', () => {
|
||||
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
|
||||
|
||||
test('ready × fail: exit 1 with the failed-check line and PR_WAIT_BLOCKED', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) });
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('any-change × pass: silent while nothing is new; one "checks finished" line once the set turns stable, then exit 0', () => {
|
||||
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] }, 'any-change');
|
||||
expect(w.poll()).toBeNull();
|
||||
w.baseline = false;
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
|
||||
});
|
||||
|
||||
test('any-change × pending: silent, keeps polling', () => {
|
||||
expect(pollUntilDone(watcher({ checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
});
|
||||
|
||||
test('any-change × fail: the failed check is an event and the watch ends with exit 0, not blocked', () => {
|
||||
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'fail']))] }, 'any-change');
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 check failed: build https://ci.example/build']);
|
||||
});
|
||||
|
||||
test('BEHIND × any-change and DIRTY × any-change: one merge-state line each, exit 0, no PR_WAIT_BLOCKED', () => {
|
||||
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
|
||||
expect(pollUntilDone(behind)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging']);
|
||||
stdout = [];
|
||||
const dirty = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'DIRTY' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
|
||||
expect(pollUntilDone(dirty)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it']);
|
||||
});
|
||||
|
||||
test('DIRTY × ready: exit 1 with the DIRTY line and PR_WAIT_BLOCKED', () => {
|
||||
const w = watcher({ pr: prJson({ mergeState: 'DIRTY', reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('UNKNOWN × green, UNKNOWN × any-change: no merge-state event; green is decided by the checks alone', () => {
|
||||
const green = watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pass'])) }, 'green');
|
||||
expect(pollUntilDone(green)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state UNKNOWN; 0 commits behind main$/)]);
|
||||
expect(JSON.parse(readFileSync(green.stateFile, 'utf8')).merge_state).toBeNull();
|
||||
stdout = [];
|
||||
expect(pollUntilDone(watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
});
|
||||
|
||||
test('lock: free × unlock is a no-op; dead-pid × unlock leaves the stale file for the next lock() to take over', () => {
|
||||
const w = watcher({});
|
||||
expect(() => w.unlock()).not.toThrow();
|
||||
expect(existsSync(w.lockFile)).toBe(false);
|
||||
mkdirSync(join(w.lockFile, '..'), { recursive: true });
|
||||
writeFileSync(w.lockFile, '999999');
|
||||
w.unlock();
|
||||
expect(readFileSync(w.lockFile, 'utf8')).toBe('999999');
|
||||
expect(w.lock()).toBeNull();
|
||||
expect(readFileSync(w.lockFile, 'utf8')).toBe('4242');
|
||||
});
|
||||
|
||||
test('restarted × required-check-failed and restarted × merged: the verdict line is printed again by the new process', () => {
|
||||
const first = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
|
||||
expect(first.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
stdout = [];
|
||||
const again = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
|
||||
expect(again.baseline).toBe(false);
|
||||
expect(again.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
|
||||
stdout = [];
|
||||
const merged = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
|
||||
expect(merged.baseline).toBe(false);
|
||||
expect(merged.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,7 +211,8 @@ describe('ship Step 9 instructions', () => {
|
||||
const army = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8').replace(/\s+/g, ' ');
|
||||
test('pass 2+ reads the range since the previous pass under a full token; the fix-range token is /review\'s alone', () => {
|
||||
expect(army).toContain('Every entry saves `PASS_HEAD=$(git rev-parse HEAD)`; from pass 2 on, `RANGE_BASE` is the previous pass\'s PASS_HEAD.');
|
||||
expect(army).toContain('use `DIFF_BASE=$RANGE_BASE` instead, so every reviewer reads what changed since the previous pass.');
|
||||
expect(army).toContain('`DIFF_BASE=<diff-base>`, the merge base on pass 1 and `RANGE_BASE` from pass 2 on.');
|
||||
expect(army).toContain('A reviewer never sees your shell\'s `RANGE_BASE`; a variable left in a prompt re-reads the whole branch.');
|
||||
expect(army).toContain('Every pass captures a full token (no `--fix-range`), so the record binds the whole tree even when reviewers read only the range.');
|
||||
expect(army).not.toContain('--start review --fix-range');
|
||||
expect(army).toContain('gstack-review-scope classify --base <base> --pass $PASS --range-base $RANGE_BASE');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* PR 2 of the /ship + /review convergence lane: the review-scope follow-ups
|
||||
* from PR 1's review notes. A Step 11 late defer is a review-log row that
|
||||
* counts under deferred and is not a pass; the deferred-issue search names its
|
||||
* page size and reads an exactly-full page as truncated; `gh repo view` takes
|
||||
* the repository positionally, so the visibility read works under --repo and a
|
||||
* MEDIUM finding on a public repository stays unfiled until a human confirms.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { ALREADY_FILED_REASON, LATE_DEFER_SKILL, renderDeferredSection, reviewLoopLine, summarizeReviewLoop } from '../lib/review-scope';
|
||||
import { reviewFreshness } from '../lib/review-evidence';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = () => { const r = makeFixtureRepo({ prefix: 'review-scope-2-' }); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
describe('late defers from Step 11', () => {
|
||||
test('a review-late-defer row adds its deferred findings, is not counted as a pass, and is invisible to review freshness', () => {
|
||||
const late = { skill: LATE_DEFER_SKILL, via: 'ship', timestamp: '2026-10-26T10:30:00Z', pass: 2, review_scope: 'frozen', findings: [
|
||||
{ fingerprint: 'c:9:z', blocking: true, action: 'deferred', issue: null }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }] };
|
||||
const rows = [
|
||||
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', pass: 1, review_scope: 'frozen', findings: [
|
||||
{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }] },
|
||||
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:20:00Z', pass: 2, review_scope: 'frozen', findings: [] },
|
||||
late,
|
||||
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:25:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true, action: 'deferred' }] },
|
||||
];
|
||||
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
|
||||
expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen' });
|
||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=2 noted=0 fixed=1 scope=frozen');
|
||||
expect(LATE_DEFER_SKILL).toBe('review-late-defer');
|
||||
// A `review` row logged without a start token would be the newest review row and read UNVERIFIED; the late-defer row is not a diff review at all.
|
||||
expect(reviewFreshness(late, 'abc')).toBeUndefined();
|
||||
expect(reviewFreshness({ ...late, skill: 'review' }, 'abc')?.status).toBe('UNVERIFIED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred-issue search page and the repository argument gh accepts', () => {
|
||||
const finding = (fingerprint: string, summary: string) => {
|
||||
const [p, line, category] = fingerprint.split(':');
|
||||
return { fingerprint, path: p, line: Number(line), severity: 'CRITICAL', category, summary, locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' };
|
||||
};
|
||||
const shim = (repo: FixtureRepo, visibility: string, issues: number) => {
|
||||
const dir = path.join(repo.root, 'gh-shim');
|
||||
fs.mkdirSync(dir);
|
||||
const log = path.join(dir, 'calls.log');
|
||||
const page = JSON.stringify(Array.from({ length: issues }, (_, i) => ({ number: 100 + i, url: `https://example.test/acme/app/issues/${100 + i}`, body: `<!-- gstack-deferred-finding: other:${i}:x -->` })));
|
||||
fs.writeFileSync(path.join(dir, 'gh'), [
|
||||
'#!/bin/sh', `echo "$*" >> "${log}"`,
|
||||
`if [ "$1" = repo ] && [ "$2" = view ]; then [ "$3" = acme/app ] || { echo "gh: --repo not accepted by repo view: $*" >&2; exit 1; }; echo "${visibility}"; exit 0; fi`,
|
||||
`if [ "$1" = issue ] && [ "$2" = list ]; then cat <<'EOF'\n${page}\nEOF\nexit 0; fi`,
|
||||
'if [ "$1" = issue ] && [ "$2" = create ]; then echo "https://example.test/acme/app/issues/900"; exit 0; fi',
|
||||
'echo "unsupported $*" >&2; exit 1', '',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
return { dir, log };
|
||||
};
|
||||
|
||||
// The gh stand-in is a sh script on a colon-joined PATH, which CreateProcess cannot run on Windows.
|
||||
test.skipIf(process.platform === 'win32')('an exactly-full search page is reported truncated and recorded on the issue it files; a short page is not', () => {
|
||||
const repo = fixture();
|
||||
const classified = path.join(repo.root, 'classified.json');
|
||||
fs.writeFileSync(classified, JSON.stringify([finding('lib/legacy.ts:7:data-loss', 'Unbounded delete')]));
|
||||
const full = shim(repo, 'PRIVATE', 200);
|
||||
const out = path.join(repo.root, 'deferred.json');
|
||||
const r = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--out', out, '--json'], { PATH: `${full.dir}:${process.env.PATH}` });
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.err).toContain('DEFERRED_SEARCH: truncated at 200 open issues; an existing issue for a finding may not have been found');
|
||||
const records = JSON.parse(r.out);
|
||||
expect(records[0]).toMatchObject({ issue: 900, reason: 'filed; the marker search was truncated at 200, so an older issue may duplicate it' });
|
||||
expect(fs.readFileSync(full.log, 'utf8')).toContain('--limit 200');
|
||||
expect(renderDeferredSection(records)).toContain('- Deferred: #900 (filed; the marker search was truncated at 200, so an older issue may duplicate it) — CRITICAL `lib/legacy.ts:7` Unbounded delete');
|
||||
expect(renderDeferredSection([{ ...records[0], issue: 9, reason: ALREADY_FILED_REASON }])).toContain('- Deferred: #9 — CRITICAL `lib/legacy.ts:7` Unbounded delete');
|
||||
const repo2 = fixture();
|
||||
const short = shim(repo2, 'PRIVATE', 199);
|
||||
const r2 = runBin(repo2, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--json'], { PATH: `${short.dir}:${process.env.PATH}` });
|
||||
expect(r2.status).toBe(EXIT.ok);
|
||||
expect(r2.err).not.toContain('DEFERRED_SEARCH');
|
||||
expect(JSON.parse(r2.out)[0]).toEqual({ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: 900, url: 'https://example.test/acme/app/issues/900' });
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('with --repo the visibility read passes the repository positionally, so a MEDIUM finding on a public repository waits for a human and stays unfiled', () => {
|
||||
const repo = fixture();
|
||||
const classified = path.join(repo.root, 'classified.json');
|
||||
fs.writeFileSync(classified, JSON.stringify([
|
||||
finding('lib/mail.ts:3:pii', 'Notification goes to jane.roe@acme-corp.io on every retry'),
|
||||
finding('lib/legacy.ts:7:data-loss', 'Unbounded delete'),
|
||||
]));
|
||||
const pub = shim(repo, 'PUBLIC', 0);
|
||||
const r = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--json'], { PATH: `${pub.dir}:${process.env.PATH}` });
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
const records = JSON.parse(r.out);
|
||||
expect(records[0].issue).toBeNull();
|
||||
expect(records[0].reason).toMatch(/^publication scan: MEDIUM pii\.email needs a human before this text is posted$/);
|
||||
expect(records[1]).toMatchObject({ issue: 900 });
|
||||
const calls = fs.readFileSync(pub.log, 'utf8');
|
||||
expect(calls).toContain('repo view acme/app --json visibility --jq .visibility');
|
||||
expect(calls).not.toMatch(/^repo view .*--repo/m);
|
||||
expect(calls.match(/^issue create /gm)?.length).toBe(1);
|
||||
expect(calls).not.toContain('jane.roe@acme-corp.io');
|
||||
expect(r.err).toContain('DEFERRED: unfiled (publication scan: MEDIUM pii.email needs a human before this text is posted) CRITICAL lib/mail.ts:3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checklist rules with ids (docs/review-policy.md)', () => {
|
||||
const checklist = fs.readFileSync(path.resolve(import.meta.dir, '../review/checklist.md'), 'utf8');
|
||||
const policy = fs.readFileSync(path.resolve(import.meta.dir, '../docs/review-policy.md'), 'utf8');
|
||||
const section = (first: string, last: string) => checklist.slice(checklist.indexOf(first), checklist.indexOf(last));
|
||||
|
||||
test('prereg-with-results is a Pass 1 gate-integrity ASK finding about the pairing, repaired by a split', () => {
|
||||
const gate = section('#### Gate Integrity', '#### Approval Classes and Data Repairs');
|
||||
expect(gate).toContain('`prereg-with-results`, ASK');
|
||||
expect(gate).toMatch(/preregistration .*results, scores or verdict/);
|
||||
expect(gate).toContain('the finding is the pairing, not either half');
|
||||
expect(gate).toMatch(/split them \(the preregistration lands first/);
|
||||
expect(policy).toContain('`prereg-with-results` (Pass 1, Gate Integrity, ASK)');
|
||||
});
|
||||
|
||||
test('test-keyboard-count-loop is a Pass 2 informational rule that keeps a counted loop only when the count is the behavior', () => {
|
||||
const pass2 = section('### Pass 2 — INFORMATIONAL', '## Sideways sweep');
|
||||
expect(pass2).toContain('#### Keyboard-Count Test Loops');
|
||||
expect(pass2).toContain('(`test-keyboard-count-loop`)');
|
||||
expect(pass2).toContain('Locate the target by role, label or test id');
|
||||
expect(pass2).toContain('a counted loop stays only when the count itself is the behavior under test');
|
||||
expect(policy).toContain('`test-keyboard-count-loop` (Pass 2, informational)');
|
||||
expect(policy).toContain('gstack-coauthor-trailers');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Free contracts of the native frozen-scope fixture (test/helpers/ship-frozen-scope-actor.ts):
|
||||
* a scripted actor that follows Step 9.4 and the repeat decision passes the
|
||||
* verdict with the real gstack-review-scope / gstack-review-log binaries, and
|
||||
* the negative controls (editing the pre-existing file, leaving the diff defect
|
||||
* in place, exiting after one pass) are named failures. No model call is made.
|
||||
*/
|
||||
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
createShipFrozenScopeFixture, FROZEN_SCOPE_DEFER_IDENTITY, FROZEN_SCOPE_FIX_IDENTITY, shipFrozenScopeFailures, shipFrozenScopeWorkflow,
|
||||
} from './helpers/ship-frozen-scope-actor';
|
||||
import { reviewLoopLine, summarizeReviewLoop } from '../lib/review-scope';
|
||||
|
||||
type Fixture = ReturnType<typeof createShipFrozenScopeFixture>;
|
||||
let workflow = '';
|
||||
const roots: string[] = [];
|
||||
beforeAll(async () => { workflow = await shipFrozenScopeWorkflow(); }, 120_000);
|
||||
afterAll(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
const SAFE = 'export const canReadInvoice = (owner: string, viewer: string) => owner === viewer;\n';
|
||||
|
||||
function fixture(): Fixture {
|
||||
const f = createShipFrozenScopeFixture(workflow);
|
||||
roots.push(f.root);
|
||||
return f;
|
||||
}
|
||||
function sh(f: Fixture, command: string): string {
|
||||
const r = spawnSync('bash', ['-c', command], { cwd: f.repo, encoding: 'utf8', env: { ...process.env, ...f.env }, timeout: 60_000 });
|
||||
if (r.status !== 0) throw new Error(`${command}\n${r.stderr}${r.stdout}`);
|
||||
return r.stdout;
|
||||
}
|
||||
async function hook(f: Fixture, tool: string, input: Record<string, unknown>): Promise<string | undefined> {
|
||||
const out: any = await f.preToolUse({ hook_event_name: 'PreToolUse', tool_name: tool, tool_input: input } as any, undefined as any, {} as any);
|
||||
return out.hookSpecificOutput.permissionDecision === 'allow' ? undefined : out.hookSpecificOutput.permissionDecisionReason;
|
||||
}
|
||||
/** A scripted actor: declared reads, classify, fix, defer, log, commit, repeat, log, continue. */
|
||||
async function act(f: Fixture, options: { fix?: boolean; passes?: 1 | 2 } = {}) {
|
||||
const { fix = true, passes = 2 } = options;
|
||||
for (const file of [f.workflowPath, f.findingsPath, f.invoice, f.payments]) expect(await hook(f, 'Read', { file_path: file })).toBeUndefined();
|
||||
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBeUndefined();
|
||||
const classify = sh(f, f.commands.classify!);
|
||||
expect(classify).toContain('REVIEW_SCOPE: policy=frozen pass=1 findings=2 fix=1 defer=1 note=0 blocking=2');
|
||||
const classified = JSON.parse(fs.readFileSync(`${f.repo}/.gstack/tmp/review-classified-p1.json`, 'utf8'));
|
||||
if (fix) {
|
||||
expect(await hook(f, 'Write', { file_path: f.invoice, content: SAFE })).toBeUndefined();
|
||||
fs.writeFileSync(f.invoice, SAFE);
|
||||
}
|
||||
expect(await hook(f, 'Bash', { command: f.commands.defer })).toBeUndefined();
|
||||
expect(sh(f, f.commands.defer!)).toContain('DEFERRED: unfiled (dry run) CRITICAL payments.ts:2');
|
||||
const findings = classified.findings.map((x: any) => ({ fingerprint: x.fingerprint, severity: x.severity, action: x.scope === 'fix' ? (fix ? 'auto-fixed' : 'skipped') : 'deferred',
|
||||
...(x.scope === 'defer' ? { issue: null } : {}), locus: x.locus, kind: x.kind, blocking: x.blocking }));
|
||||
const row = (extra: Record<string, unknown>) => ({ skill: 'review', timestamp: new Date().toISOString(), status: 'issues_found', issues_found: 0, critical: 0, informational: 0,
|
||||
quality_score: 10.0, specialists: {}, findings, commit: sh(f, 'git rev-parse --short HEAD').trim(), via: 'ship', completed: true, converged: false, cycles: 1, pass: 1,
|
||||
review_scope: 'frozen', range_base: null, ...extra });
|
||||
const log = async (record: Record<string, unknown>) => {
|
||||
expect(await hook(f, 'Write', { file_path: f.draft, content: JSON.stringify(record) })).toBeUndefined();
|
||||
fs.writeFileSync(f.draft, JSON.stringify(record));
|
||||
expect(await hook(f, 'Bash', { command: f.commands.persist })).toBeUndefined();
|
||||
sh(f, f.commands.persist!);
|
||||
};
|
||||
await log(row({}));
|
||||
if (passes === 1) {
|
||||
expect(await hook(f, 'Bash', { command: f.commands.continue })).toBeUndefined();
|
||||
sh(f, f.commands.continue!);
|
||||
return;
|
||||
}
|
||||
expect(await hook(f, 'Bash', { command: f.commands.commit })).toBeUndefined();
|
||||
sh(f, f.commands.commit!);
|
||||
expect(await hook(f, 'Bash', { command: f.commands.repeat })).toBeUndefined();
|
||||
const repeat = JSON.parse(sh(f, f.commands.repeat!));
|
||||
expect(repeat).toMatchObject({ pass: 2, findings: [], coverage: 'completed' });
|
||||
expect(await hook(f, 'Read', { file_path: `${f.repo}/.gstack/tmp/review-findings-p2.json` })).toBeUndefined();
|
||||
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBeUndefined();
|
||||
expect(sh(f, f.commands.classify!)).toContain('REVIEW_SCOPE: policy=frozen pass=2 findings=0 fix=0 defer=0 note=0 blocking=0');
|
||||
await log(row({ status: 'clean', findings: [], converged: true, pass: 2, range_base: f.snapshot().head }));
|
||||
expect(await hook(f, 'Bash', { command: f.commands.continue })).toBeUndefined();
|
||||
sh(f, f.commands.continue!);
|
||||
}
|
||||
|
||||
// The actor replays the bash workflow through bash -c and the shebang bins, so it is POSIX-only like the paid case it mirrors.
|
||||
const posixTest = test.skipIf(process.platform === 'win32');
|
||||
posixTest('a scripted actor that fixes the diff, defers the pre-existing defect and converges on pass 2 passes the verdict', async () => {
|
||||
const f = fixture();
|
||||
await act(f);
|
||||
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
|
||||
expect(verdict.failures).toEqual([]);
|
||||
expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, scope: 'frozen' });
|
||||
const rows = verdict.evidence.rows as Array<Record<string, any>>;
|
||||
expect(rows.map(r => r.pass)).toEqual([1, 2]);
|
||||
expect(rows[0]!.findings.map((x: any) => [x.fingerprint, x.action])).toEqual([[FROZEN_SCOPE_FIX_IDENTITY, 'auto-fixed'], [FROZEN_SCOPE_DEFER_IDENTITY, 'deferred']]);
|
||||
expect(rows[1]!.range_base).toBe(verdict.evidence.head);
|
||||
expect(verdict.evidence.payments).toBe(verdict.evidence.originalPayments);
|
||||
const summary = summarizeReviewLoop(rows, verdict.evidence.start.started_at);
|
||||
expect(summary).toEqual(verdict.evidence.loop);
|
||||
expect(reviewLoopLine(summary)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=0 fixed=1 scope=frozen');
|
||||
}, 120_000);
|
||||
|
||||
posixTest('the fixture interface refuses the undeclared interactions before any decision', async () => {
|
||||
const f = fixture();
|
||||
expect(await hook(f, 'Edit', { file_path: f.payments, old_string: 'card', new_string: '?' })).toBe('Edit outside invoice.ts');
|
||||
expect(await hook(f, 'Write', { file_path: f.payments, content: '' })).toBe('Write outside invoice.ts and the review record');
|
||||
expect(await hook(f, 'Bash', { command: `${f.commands.classify} && echo done` })).toBe('Bash outside the fixture interface');
|
||||
expect(await hook(f, 'Bash', { command: 'git status' })).toBe('Bash outside the fixture interface');
|
||||
expect(await hook(f, 'Read', { file_path: `${f.root}/fixture.json` })).toBe('Read outside the declared files');
|
||||
expect(await hook(f, 'AskUserQuestion', { questions: [] })).toBe('Undeclared tool');
|
||||
expect(() => sh(f, f.commands.repeat!)).toThrow(/committed/);
|
||||
expect(() => sh(f, f.commands.continue!)).toThrow(/logged/);
|
||||
expect(f.snapshot().executions.filter(e => !e.allowed)).toHaveLength(6);
|
||||
}, 120_000);
|
||||
|
||||
posixTest('leaving the diff defect in place or exiting after one pass are named failures', async () => {
|
||||
const f = fixture();
|
||||
await act(f, { fix: false, passes: 1 });
|
||||
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
|
||||
expect(verdict.failures).toContain('invoice.ts still accepts a viewer other than the owner');
|
||||
expect(verdict.failures).toContain('expected exactly one repeat pass');
|
||||
expect(verdict.failures).toContain('pass 1 did not record the in-diff defect as fixed');
|
||||
expect(verdict.failures.some(line => line.startsWith('REVIEW_LOOP summary'))).toBe(true);
|
||||
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBe('Loop decision already taken');
|
||||
}, 120_000);
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Free contracts of the receipt-versus-prose number check (lib/ship-receipt-prose.ts,
|
||||
* gstack-ship-receipt read --check-prose): claims in the three spellings are
|
||||
* read against review_loop, gate and ci integers, the receipt block itself is
|
||||
* never a claim, a disagreement is RECEIPT_PROSE_MISMATCH (exit 1), and prose
|
||||
* without counters passes.
|
||||
*/
|
||||
import { 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 { spawnSync } from 'node:child_process';
|
||||
import { checkReceiptProse, proseLine, receiptCounters } from '../lib/ship-receipt-prose';
|
||||
import { renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin/gstack-ship-receipt');
|
||||
const receipt = {
|
||||
schema_version: 1 as const, pr: 7, head: 'a'.repeat(40), base: 'main', version: '1.2.3.0',
|
||||
review_loop: { passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, scope: 'frozen' },
|
||||
gate: { status: 'green', passed: 41, failed: 0, run: '123' }, ci: { status: 'success' },
|
||||
};
|
||||
|
||||
describe('checkReceiptProse', () => {
|
||||
test('counters are the integer leaves under review_loop, gate and ci; identifiers are never counters', () => {
|
||||
expect([...receiptCounters(receipt).keys()]).toEqual(['review_loop.passes', 'review_loop.blocking_found', 'review_loop.deferred',
|
||||
'review_loop.noted', 'review_loop.fixed', 'gate.passed', 'gate.failed']);
|
||||
});
|
||||
|
||||
test('the three spellings are claims, the receipt block is not, and a disagreement names field and both values', () => {
|
||||
const body = 'Review: 2 passes, 1 deferred finding, blocking found: 2. Gate: passed=41, 0 failed.\n\n' + renderReceiptBlock({ ...receipt, review_loop: { ...receipt.review_loop, passes: 9 } });
|
||||
const ok = checkReceiptProse(receipt, body);
|
||||
expect(ok).toEqual({ checked: 5, mismatches: [] });
|
||||
expect(proseLine(ok)).toBe('PROSE: 5 receipt counter claims in the body, all match');
|
||||
const bad = checkReceiptProse(receipt, 'The loop took 3 passes; deferred=2; 41 passed.');
|
||||
expect(bad.mismatches).toEqual([{ field: 'review_loop.passes', prose: 3, receipt: 2 }, { field: 'review_loop.deferred', prose: 2, receipt: 1 }]);
|
||||
expect(proseLine(bad)).toBe('PROSE: mismatch review_loop.passes body=3 receipt=2; review_loop.deferred body=2 receipt=1');
|
||||
expect(proseLine(checkReceiptProse(receipt, 'PR #7 adds the docs.'))).toBe('PROSE: no receipt counter claims in the body');
|
||||
});
|
||||
|
||||
test('a leaf key shared by several counters matches any of them, and a hyphenated word is not a claim', () => {
|
||||
// /ship writes the gate per lane ({"<lane>":{pass,fail,skip}}), so `pass` names every lane and `passed` names gate and ci alike.
|
||||
const lanes = { ...receipt, gate: { unit: { pass: 41, fail: 0, skip: 2 }, e2e: { pass: 7, fail: 0, skip: 0 } }, ci: { status: 'success', passed: 31, failed: 0 } };
|
||||
expect(checkReceiptProse(lanes, 'unit: pass=41, e2e pass: 7; CI passed: 31.')).toEqual({ checked: 3, mismatches: [] });
|
||||
expect(checkReceiptProse(lanes, '3 pass-through tests and the fail-fast path; blocking-found: 2.')).toEqual({ checked: 1, mismatches: [] });
|
||||
const bad = checkReceiptProse(lanes, 'pass: 40');
|
||||
expect(bad.mismatches).toEqual([{ field: 'gate.unit.pass', prose: 40, receipt: 41 }]);
|
||||
expect(checkReceiptProse(receipt, 'Gate passed: 41. CI passed=41.').mismatches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-receipt read --check-prose', () => {
|
||||
const run = (body: string) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));
|
||||
const file = path.join(dir, 'body.md');
|
||||
fs.writeFileSync(file, body);
|
||||
const r = spawnSync(BIN, ['read', '--body', file, '--check-prose', '--json'], { encoding: 'utf8', timeout: 60_000 });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
return { status: r.status, out: r.stdout, err: r.stderr };
|
||||
};
|
||||
|
||||
test('a matching body exits 0 with the PROSE line and the receipt; a mismatch is RECEIPT_PROSE_MISMATCH exit 1', () => {
|
||||
const good = run('Two passes: passes=2, deferred: 1.\n\n' + renderReceiptBlock(receipt));
|
||||
expect(good.status).toBe(EXIT.ok);
|
||||
expect(good.err).toContain('PROSE: 2 receipt counter claims in the body, all match');
|
||||
expect(JSON.parse(good.out).prose).toEqual({ checked: 2, mismatches: [] });
|
||||
const bad = run('Three passes: passes=3.\n\n' + renderReceiptBlock(receipt));
|
||||
expect(bad.status).toBe(EXIT.fail);
|
||||
expect(bad.err).toContain('PROSE: mismatch review_loop.passes body=3 receipt=2');
|
||||
expect(bad.err).toMatch(/review_loop\.passes body=3 receipt=2.*\(RECEIPT_PROSE_MISMATCH; .*troubleshooting\.md#receipt-prose-mismatch\)/);
|
||||
expect(JSON.parse(bad.out).prose.mismatches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -23,17 +23,18 @@ test('native-only fixture files do not select quality judges', () => {
|
||||
expect(selectTests(['qa-only/SKILL.md.tmpl'], E2E_TOUCHFILES).selected).not.toContain(id);
|
||||
});
|
||||
|
||||
test('scratch identity helper selects only queued-Skip and preserves its fast-profile deferral', () => {
|
||||
test('scratch identity helper selects only the two native ship fixtures and preserves their fast-profile deferral', () => {
|
||||
const changedFiles = ['test/helpers/scratch-repo.ts'];
|
||||
expect(selectTests(changedFiles, E2E_TOUCHFILES).selected).toEqual([id]);
|
||||
const scratchIds = [id, 'ship-frozen-scope-convergence'];
|
||||
expect(selectTests(changedFiles, E2E_TOUCHFILES).selected.sort()).toEqual([...scratchIds].sort());
|
||||
const full = computePaidCaseSelection({ profile: 'full', env: {}, changedFiles });
|
||||
expect(full.selection).toEqual({ e2e: [id], judges: [] });
|
||||
expect([...(full.selection.e2e ?? [])].sort()).toEqual([...scratchIds].sort());
|
||||
expect(full.selection.judges).toEqual([]);
|
||||
const pr = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles });
|
||||
expect(pr.selection).toEqual({ e2e: [], judges: [] });
|
||||
expect(pr.coverage?.mode).toBe('pr');
|
||||
expect(pr.coverage?.deferred).toEqual([{
|
||||
id, tier: 'gate', reason: 'Broad gate census/release coverage; outside the fast PR profile',
|
||||
}]);
|
||||
expect(pr.coverage?.deferred.map(({ id }) => id).sort()).toEqual([...scratchIds].sort());
|
||||
expect(pr.coverage?.deferred.map(({ tier, reason }) => `${tier}: ${reason}`)).toEqual(scratchIds.map(() => 'gate: Broad gate census/release coverage; outside the fast PR profile'));
|
||||
});
|
||||
|
||||
test.each(['test/helpers/qa-checkpoint-evidence.ts', 'test/fixtures/qa-only-charter-public.json'])
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { ALL_HOST_CONFIGS } from '../hosts';
|
||||
import { generateAdversarialStep } from '../scripts/resolvers/outside-voice-steps';
|
||||
import { generateQAReview } from '../scripts/resolvers/qa';
|
||||
import { generateReviewArmy } from '../scripts/resolvers/review-army';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
import { between, compact, expectAbsent, expectMentions, expectOrdered, expectTokens } from './helpers/prompt-structure';
|
||||
import { readWorkflowExcerpt } from './helpers/workflow-excerpt';
|
||||
@@ -206,3 +207,39 @@ test('ship resolves branch and installed-asset references without competing inst
|
||||
expect(row, 'ship section index lost its exploratory QA row').toBeDefined();
|
||||
expectTokens(row!, ['`sections/review-army.md`'], 'ship section index');
|
||||
});
|
||||
|
||||
test('a repository without a version source or CHANGELOG ships with one skipped line per step and a receipt that says version none (E18)', () => {
|
||||
const step12 = compact(between(entryTemplate, '## Step 12:', '{{SECTION:changelog}}'));
|
||||
expectTokens(step12, ['**NO_VERSION**', '`Version bump: skipped (no version source: <path, reason>)`', 'never create VERSION', '`"version":null`'], 'ship Step 12');
|
||||
const changelog = compact(readTemplate('ship/sections/changelog.md.tmpl'));
|
||||
expectTokens(changelog, ['**NO_VERSION (Step 12):**', '**No CHANGELOG:**', '`CHANGELOG: skipped (none at <path>)`', 'never create the file', '`"none"`'], 'ship changelog section');
|
||||
expectOrdered(changelog, ['**NO_VERSION (Step 12):**', '**No CHANGELOG:**'], 'ship changelog section');
|
||||
});
|
||||
|
||||
test('Step 14.5 runs the doc caps before the verification gate and records the audit for the receipt, with the owner exception shape (E11)', () => {
|
||||
// The caps come from origin/<base>'s policy (a branch cannot raise its own cap), and the per-invocation record is cleared before the first launch.
|
||||
expectOrdered(docsTemplate, ['rm -f .gstack/tmp/docs-audit.json', '### Continue or recover', 'gstack-ship-receipt docs-caps --base <base>', 'never the\nbranch\'s copy', '`DOCS_CAP: <path> <bytes>/<max> ok|over`', 'write the receipt\'s record to `.gstack/tmp/docs-audit.json`', '## Blocked recovery'], 'ship documentation section');
|
||||
expectAbsent(docsTemplate, ['docs-caps --policy .gstack/ship-policy.json'], 'ship documentation section cap source');
|
||||
expectTokens(docs, ['`DOCS_CAP: none declared`', '"status":"updated|current"', '"status":"blocked"', '"exception":{"accepted_by"', '`DOCS_AUDIT_BLOCKED`', 'An unattended run has no owner to accept: it stops here.'], 'ship documentation section');
|
||||
const compose = between(prTemplate, 'Handoff record first', '**Existing open PR/MR**');
|
||||
expectTokens(compose, ['--docs @.gstack/tmp/docs-audit.json', '`DOCS_AUDIT_BLOCKED`'], 'ship pr-body receipt write');
|
||||
expectTokens(compact(between(prTemplate, '## Documentation', '## Test plan')), ['`Documentation exception: accepted by <accepted_by> <date> via <via>: <reason>`'], 'ship pr-body Documentation');
|
||||
});
|
||||
|
||||
test('Step 9.4 applies the cap before any edit, names its scratch files under .gstack/tmp/, logs Step 11 late defers, and hands reviewers the range from pass 2 (PR 1 review notes)', () => {
|
||||
const fixFirst = between(reviewTemplate, '## Step 9.4:', '### Decide whether to repeat Step 9');
|
||||
expectOrdered(fixFirst, ['0. **Scope the findings.**', '.gstack/tmp/review-findings-p$PASS.json', '.gstack/tmp/review-classified-p$PASS.json', '1. **Apply the cap before any edit, then classify.**', '2. **Auto-fix all AUTO-FIX items.**', '**Late defers from Step 11.**', '"skill":"review-late-defer"', '6. Persist the review result'], 'ship Step 9.4');
|
||||
expectTokens(compact(fixFirst), ['`.gstack/tmp/review-deferred.json`', '`CYCLES >= 3`', '`PASS >= 3`', 'no `--finish` token'], 'ship Step 9.4');
|
||||
// The sha is written into each dispatched prompt: a reviewer's shell never sees the parent's RANGE_BASE, so a variable there would silently widen the read.
|
||||
expectTokens(reviewFlow, ['`DIFF_BASE=<diff-base>`', 'RANGE_BASE..HEAD', 'never sees your shell\'s `RANGE_BASE`'], 'ship Step 9 range');
|
||||
expectAbsent(reviewFlow, ['DIFF_BASE=${RANGE_BASE'], 'ship Step 9 range fallback');
|
||||
for (const host of ALL_HOST_CONFIGS) {
|
||||
const army = generateReviewArmy({ host: host.name, skillName: 'ship', tmplPath: '', paths: HOST_PATHS[host.name] });
|
||||
if (!army) continue;
|
||||
expect(army.match(/DIFF_BASE=<diff-base>/g)?.length, `${host.name} ship review army range`).toBe(2);
|
||||
expectAbsent(army, ['${RANGE_BASE'], `${host.name} ship review army fallback`);
|
||||
expectTokens(army, ['--review-fanout auto|serial|parallel', /Fan-out: N specialists in\s+M batches of K/], `${host.name} ship review army fan-out`);
|
||||
const review = generateReviewArmy({ host: host.name, skillName: 'review', tmplPath: '', paths: HOST_PATHS[host.name] });
|
||||
expectAbsent(review, ['RANGE_BASE', '--review-fanout'], `${host.name} /review army`);
|
||||
}
|
||||
});
|
||||
@@ -96,15 +96,18 @@ function setupPlanDir(tmpPrefix: string, skillName: 'plan-ceo-review' | 'plan-en
|
||||
run('git', ['commit', '-m', 'add plan']);
|
||||
|
||||
fs.mkdirSync(path.join(planDir, skillName), { recursive: true });
|
||||
fs.copyFileSync(
|
||||
path.join(ROOT, skillName, 'SKILL.md'),
|
||||
const sections = path.join(planDir, skillName, 'sections');
|
||||
fs.writeFileSync(
|
||||
path.join(planDir, skillName, 'SKILL.md'),
|
||||
fs.readFileSync(path.join(ROOT, skillName, 'SKILL.md'), 'utf-8')
|
||||
.replaceAll(`~/.claude/skills/gstack/${skillName}/sections/`, sections + '/'),
|
||||
);
|
||||
fs.cpSync(path.join(ROOT, skillName, 'sections'), sections, { recursive: true });
|
||||
|
||||
return planDir;
|
||||
}
|
||||
|
||||
// The capture instruction passed to every case except the approach menu (D2).
|
||||
// The capture instruction passed to every case except the mode and approach menus (D2).
|
||||
// Tells the agent to dump AskUserQuestion content to a file instead of calling a tool.
|
||||
function captureInstruction(outFile: string): string {
|
||||
return `Write the verbatim text of every AskUserQuestion you would have made to ${outFile} (one question per session, full text including options and recommendation line). Do NOT call any tool to ask the user. Do NOT paraphrase — include the exact prose you would have shown. This is a format-capture test, not an interactive session.`;
|
||||
@@ -115,7 +118,8 @@ function captureInstruction(outFile: string): string {
|
||||
* `reasoning_extraction`, "duplicating model outputs") were anchored on the
|
||||
* session prompt. This wording delivers the same question to the file as the
|
||||
* step's output, without the "verbatim ... exact prose ... format-capture"
|
||||
* phrasing.
|
||||
* phrasing. The mode menu uses it too: both of its reds on the GSTA-96
|
||||
* fixture repair were refusals at the capture Write (`reasoning_extraction`).
|
||||
*/
|
||||
function approachQuestionInstruction(outFile: string): string {
|
||||
return `This session has no interactive question tool, so do not call any tool to ask the user. Write the one AskUserQuestion this step asks to ${outFile} instead: the question text, every option, and the Recommendation line, formatted as the skill specifies.`;
|
||||
@@ -150,7 +154,7 @@ Read plan.md — that's the plan to review. This is a standalone plan document,
|
||||
|
||||
Proceed to Mode Selection. This is where the skill presents 4 mode options (SCOPE EXPANSION, SELECTIVE EXPANSION, HOLD SCOPE, SCOPE REDUCTION) to the user via AskUserQuestion. These options differ in kind (review posture), not in coverage.
|
||||
|
||||
${captureInstruction(outFile)}
|
||||
${approachQuestionInstruction(outFile)}
|
||||
|
||||
After writing the file, stop. Do not continue the review.`,
|
||||
workingDirectory: planDir,
|
||||
|
||||
@@ -126,10 +126,13 @@ function setupPlanDir(tmpPrefix: string, planContent: string, skillName: string)
|
||||
run('git', ['commit', '-m', 'add plan']);
|
||||
|
||||
fs.mkdirSync(path.join(planDir, skillName), { recursive: true });
|
||||
fs.copyFileSync(
|
||||
path.join(ROOT, skillName, 'SKILL.md'),
|
||||
const sections = path.join(planDir, skillName, 'sections');
|
||||
fs.writeFileSync(
|
||||
path.join(planDir, skillName, 'SKILL.md'),
|
||||
fs.readFileSync(path.join(ROOT, skillName, 'SKILL.md'), 'utf-8')
|
||||
.replaceAll(`~/.claude/skills/gstack/${skillName}/sections/`, sections + '/'),
|
||||
);
|
||||
fs.cpSync(path.join(ROOT, skillName, 'sections'), sections, { recursive: true });
|
||||
|
||||
return planDir;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { afterAll, test } from 'bun:test';
|
||||
import { CAPTURE_MS } from './helpers/eval-budgets';
|
||||
import { describeE2ETier, e2eTierEnabled } from './helpers/e2e-gate';
|
||||
import { EvalCollector } from './helpers/eval-store';
|
||||
import { runShipFrozenScopeActor, SHIP_FROZEN_SCOPE_SUITE } from './helpers/ship-frozen-scope-actor';
|
||||
|
||||
const describeE2E = describeE2ETier('gate');
|
||||
const collector = e2eTierEnabled('gate') ? new EvalCollector('e2e', undefined, SHIP_FROZEN_SCOPE_SUITE) : null;
|
||||
describeE2E('/ship frozen review scope converges in two passes', () => {
|
||||
test('ship-frozen-scope-convergence', async () => {
|
||||
await runShipFrozenScopeActor(entry => collector!.addTest(entry));
|
||||
}, CAPTURE_MS);
|
||||
});
|
||||
afterAll(async () => { await collector?.finalize(); });
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Fixture test for bin/gstack-worktree-audit: a bare origin, a main clone and
|
||||
* worktrees in every bucket the script knows, with a fake gh on PATH and a
|
||||
* Claude Code transcript directory under CLAUDE_CONFIG_DIR. The audit reads
|
||||
* only: the fixture is byte-compared before and after.
|
||||
*/
|
||||
import { afterAll, beforeAll, 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";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BIN = path.join(ROOT, "bin", "gstack-worktree-audit");
|
||||
|
||||
let tmp: string;
|
||||
let repo: string;
|
||||
let origin: string;
|
||||
let fakeBin: string;
|
||||
let claudeDir: string;
|
||||
const wts: Record<string, string> = {};
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
function commit(cwd: string, file: string, text: string, msg: string): void {
|
||||
fs.writeFileSync(path.join(cwd, file), text);
|
||||
git(cwd, "add", file);
|
||||
git(cwd, "commit", "-q", "-m", msg);
|
||||
}
|
||||
function audit(...args: string[]) {
|
||||
const r = spawnSync("bash", [BIN, ...args], {
|
||||
cwd: repo, encoding: "utf8", timeout: 60_000,
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp },
|
||||
});
|
||||
const lines = r.stdout.split("\n").filter(Boolean);
|
||||
const header = lines[1]?.split("\t") ?? [];
|
||||
const rows = lines.slice(2).map(l => Object.fromEntries(l.split("\t").map((v, i) => [header[i]!, v])) as Record<string, string>);
|
||||
return { status: r.status, stderr: r.stderr, base: lines[0], header, rows, byPath: Object.fromEntries(rows.map(r => [r.WORKTREE, r])) };
|
||||
}
|
||||
function snapshot(dir: string): string {
|
||||
const r = spawnSync("bash", ["-c", "find . -path ./.git -prune -o -type f -print0 | sort -z | xargs -0 sha256sum; git -C . status --porcelain; git -C . for-each-ref; git -C . worktree list --porcelain"], { cwd: dir, encoding: "utf8", timeout: 30_000 });
|
||||
return r.stdout;
|
||||
}
|
||||
// The fake gh records its argv and, when jq is installed, runs the audit's own --jq expression over
|
||||
// canned JSON (so the fork-PR filter is exercised); without jq it prints the TSV that expression yields.
|
||||
function fakeGh(prs: Array<{ number: number; state: string; headRefName: string; isCrossRepository: boolean }>): void {
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.json"), JSON.stringify(prs));
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.tsv"), prs.filter(p => !p.isCrossRepository).map(p => `${p.headRefName}\t${p.number}\t${p.state.toLowerCase()}`).join("\n") + "\n");
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), `#!/usr/bin/env bash
|
||||
printf '%s\\n' "$@" > "${fakeBin}/gh-args"
|
||||
expr=""; while [ $# -gt 0 ]; do [ "$1" = --jq ] && expr="$2"; shift; done
|
||||
if command -v jq >/dev/null 2>&1 && [ -n "$expr" ]; then jq -r "$expr" "${fakeBin}/prs.json"; else cat "${fakeBin}/prs.tsv"; fi
|
||||
`, { mode: 0o755 });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-wt-audit-"));
|
||||
origin = path.join(tmp, "origin.git");
|
||||
repo = path.join(tmp, "repo");
|
||||
fs.mkdirSync(origin);
|
||||
git(origin, "init", "-q", "--bare", "--initial-branch=main");
|
||||
git(tmp, "clone", "-q", origin, repo);
|
||||
git(repo, "checkout", "-q", "-b", "main");
|
||||
commit(repo, "README.md", "# fixture\n", "initial");
|
||||
git(repo, "push", "-q", "-u", "origin", "main");
|
||||
|
||||
// merged: a branch whose commit was fast-forwarded into main and pushed; clean tree, branch never pushed.
|
||||
wts.merged = path.join(tmp, "wt-merged");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/merged", wts.merged);
|
||||
commit(wts.merged, "merged.txt", "done\n", "merged work");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/merged");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
|
||||
// wip: pushed branch with a tracked edit on top; its PR is open.
|
||||
wts.wip = path.join(tmp, "wt-wip");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/wip", wts.wip);
|
||||
commit(wts.wip, "wip.txt", "v1\n", "wip work");
|
||||
git(wts.wip, "push", "-q", "-u", "origin", "feat/wip");
|
||||
fs.writeFileSync(path.join(wts.wip, "wip.txt"), "v2 edited\n");
|
||||
|
||||
// scratch: an unpushed branch, two untracked files, a chat from today → verify-recent-chat.
|
||||
wts.scratch = path.join(tmp, "wt-scratch");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/scratch", wts.scratch);
|
||||
commit(wts.scratch, "scratch.txt", "s\n", "scratch work");
|
||||
fs.writeFileSync(path.join(wts.scratch, "notes.md"), "untracked\n");
|
||||
fs.writeFileSync(path.join(wts.scratch, "tmp.log"), "untracked\n");
|
||||
claudeDir = path.join(tmp, "claude");
|
||||
const chat = path.join(claudeDir, "projects", wts.scratch.replace(/[^A-Za-z0-9]/g, "-"));
|
||||
fs.mkdirSync(chat, { recursive: true });
|
||||
fs.writeFileSync(path.join(chat, "session.jsonl"), "{}\n");
|
||||
|
||||
// stale: an unpushed branch nobody touched, no chat, no PR → review.
|
||||
wts.stale = path.join(tmp, "wt-stale");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/stale", wts.stale);
|
||||
commit(wts.stale, "stale.txt", "s\n", "stale work");
|
||||
|
||||
// closed: pushed branch whose PR was closed without merging → safe.
|
||||
wts.closed = path.join(tmp, "wt-closed");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/closed", wts.closed);
|
||||
commit(wts.closed, "closed.txt", "c\n", "closed work");
|
||||
git(wts.closed, "push", "-q", "-u", "origin", "feat/closed");
|
||||
|
||||
fakeBin = path.join(tmp, "fakebin");
|
||||
fs.mkdirSync(fakeBin);
|
||||
|
||||
// spaced: a worktree whose path has a space, merged into main, clean → safe.
|
||||
wts.spaced = path.join(tmp, "wt with space");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/spaced", wts.spaced);
|
||||
commit(wts.spaced, "spaced.txt", "s\n", "spaced work");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/spaced");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
|
||||
// fresh: head is in main but an untracked file exists only here → review, not safe.
|
||||
wts.fresh = path.join(tmp, "wt-fresh");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/fresh", wts.fresh);
|
||||
fs.writeFileSync(path.join(wts.fresh, "newfeature.ts"), "export const x = 1;\n");
|
||||
|
||||
// gone: registered, directory deleted → prunable.
|
||||
wts.gone = path.join(tmp, "wt-gone");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/gone", wts.gone);
|
||||
fs.rmSync(wts.gone, { recursive: true, force: true });
|
||||
|
||||
// locked: registered and locked, directory gone → review (prune would not touch it).
|
||||
wts.locked = path.join(tmp, "wt-locked");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/locked", wts.locked);
|
||||
git(repo, "worktree", "lock", wts.locked);
|
||||
fs.rmSync(wts.locked, { recursive: true, force: true });
|
||||
|
||||
// broken: a gitignored worktree inside the clone whose .git file is gone, with a
|
||||
// precious file only there; git -C would describe the clone itself → review.
|
||||
fs.appendFileSync(path.join(repo, ".git", "info", "exclude"), ".wt\n");
|
||||
wts.broken = path.join(repo, ".wt", "broken");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/broken", wts.broken);
|
||||
fs.writeFileSync(path.join(wts.broken, "precious.txt"), "only here\n");
|
||||
fs.rmSync(path.join(wts.broken, ".git"));
|
||||
|
||||
// tagged: merged and clean, with a tag of the same name as its branch and an open PR → hold-open-pr.
|
||||
wts.tagged = path.join(tmp, "wt-tagged");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/tagged", wts.tagged);
|
||||
git(repo, "tag", "feat/tagged");
|
||||
|
||||
// ignored: merged and clean, with a gitignored .env → safe, DIRTY names the ignored entry.
|
||||
wts.ignored = path.join(tmp, "wt-ignored");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/ignored", wts.ignored);
|
||||
fs.writeFileSync(path.join(wts.ignored, ".gitignore"), ".env\n");
|
||||
git(wts.ignored, "add", ".gitignore");
|
||||
git(wts.ignored, "commit", "-q", "-m", "ignore .env");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/ignored");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
fs.writeFileSync(path.join(wts.ignored, ".env"), "SECRET=1\n");
|
||||
});
|
||||
// Each test starts from the same gh (one open, one closed, one fork PR on feat/stale that must be
|
||||
// ignored) and the same closed worktree, whatever an earlier test changed.
|
||||
beforeEach(() => {
|
||||
fakeGh([
|
||||
{ number: 41, state: "OPEN", headRefName: "feat/wip", isCrossRepository: false },
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 99, state: "OPEN", headRefName: "feat/stale", isCrossRepository: true },
|
||||
{ number: 43, state: "OPEN", headRefName: "feat/tagged", isCrossRepository: false },
|
||||
]);
|
||||
git(wts.closed, "reset", "-q", "--hard", "origin/feat/closed");
|
||||
});
|
||||
afterAll(() => {
|
||||
if (repo) spawnSync("git", ["worktree", "prune"], { cwd: repo, timeout: 30_000 });
|
||||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("gstack-worktree-audit", () => {
|
||||
test("classifies every worktree, reads the PR column through gh, the chat through CLAUDE_CONFIG_DIR, and changes nothing", () => {
|
||||
const live = Object.values(wts).filter(w => fs.existsSync(w));
|
||||
const before = live.map(snapshot).join("\n") + snapshot(repo);
|
||||
const r = audit();
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("warn: no -b given; MERGED checks against origin/main");
|
||||
expect(r.base).toBe("# base: origin/main");
|
||||
expect(r.header).toEqual(["SIZE", "AGE", "MERGED", "DIRTY", "REMOTE", "PR", "LAST_CHAT", "BUCKET", "WORKTREE"]);
|
||||
expect(r.rows).toHaveLength(12);
|
||||
expect(r.byPath[repo]).toBeUndefined();
|
||||
expect(r.byPath[wts.locked]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "review" });
|
||||
expect(r.byPath[wts.broken]).toMatchObject({ DIRTY: "broken", BUCKET: "review" });
|
||||
expect(r.byPath[wts.tagged]).toMatchObject({ MERGED: "YES", DIRTY: "clean", PR: "#43/open", BUCKET: "hold-open-pr" });
|
||||
expect(r.byPath[wts.ignored]).toMatchObject({ MERGED: "YES", DIRTY: "clean,ignored:1", BUCKET: "safe" });
|
||||
const ghArgs = fs.readFileSync(path.join(fakeBin, "gh-args"), "utf8").split("\n");
|
||||
expect(ghArgs).toContain("number,state,headRefName,isCrossRepository");
|
||||
expect(ghArgs.find(a => a.startsWith(".[]"))).toContain("select(.isCrossRepository | not)");
|
||||
expect(r.byPath[wts.spaced]).toMatchObject({ MERGED: "YES", DIRTY: "clean", BUCKET: "safe" });
|
||||
expect(r.byPath[wts.fresh]).toMatchObject({ MERGED: "YES", DIRTY: "scratch:1", REMOTE: "no-remote", BUCKET: "review" });
|
||||
expect(r.byPath[wts.gone]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "prunable" });
|
||||
expect(r.rows.slice(-2).map(row => row.WORKTREE).sort()).toEqual([wts.gone, wts.locked].sort());
|
||||
|
||||
const merged = r.byPath[wts.merged]!;
|
||||
expect(merged).toMatchObject({ MERGED: "YES", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "safe", AGE: "0d" });
|
||||
expect(merged.SIZE).toMatch(/^\d+(\.\d+)?[KMG]?$/);
|
||||
|
||||
expect(r.byPath[wts.wip]).toMatchObject({ MERGED: "no", DIRTY: "wip:1", REMOTE: "pushed", PR: "#41/open", BUCKET: "hold-wip" });
|
||||
const scratch = r.byPath[wts.scratch]!;
|
||||
expect(scratch).toMatchObject({ MERGED: "no", DIRTY: "scratch:2", REMOTE: "no-remote", PR: "-", BUCKET: "verify-recent-chat" });
|
||||
expect(scratch.LAST_CHAT).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(r.byPath[wts.stale]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "review" });
|
||||
expect(r.byPath[wts.closed]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "pushed", PR: "#40/closed", BUCKET: "safe" });
|
||||
|
||||
const after = live.map(snapshot).join("\n") + snapshot(repo);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
test("status.showUntrackedFiles=no in the user's config does not hide untracked files", () => {
|
||||
const r = spawnSync("bash", [BIN, "--no-fetch"], {
|
||||
cwd: repo, encoding: "utf8", timeout: 60_000,
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "status.showUntrackedFiles", GIT_CONFIG_VALUE_0: "no" },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const row = r.stdout.split("\n").find(l => l.endsWith("\t" + wts.fresh))!;
|
||||
expect(row.split("\t").slice(3, 4)).toEqual(["scratch:1"]);
|
||||
expect(row.split("\t")[7]).toBe("review");
|
||||
});
|
||||
|
||||
test("an unpushed commit on a branch with a closed PR is review, an open PR on the same name wins and holds it, and -b picks the base", () => {
|
||||
git(wts.closed, "commit", "-q", "--allow-empty", "-m", "one more");
|
||||
const r1 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r1.stderr).not.toContain("warn: no -b given");
|
||||
expect(r1.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#40/closed", BUCKET: "review" });
|
||||
fakeGh([
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 42, state: "OPEN", headRefName: "feat/closed", isCrossRepository: false },
|
||||
]);
|
||||
const r2 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r2.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#42/open", BUCKET: "hold-open-pr" });
|
||||
});
|
||||
|
||||
test("without gh the PR column is - with a warning, and a missing base ref or repo is an error", () => {
|
||||
// A PATH with every tool the audit uses except gh. git goes in as the real binary from its
|
||||
// exec path, since a distribution's /usr/bin/git may be a wrapper script that looks git up on PATH.
|
||||
const noGh = path.join(tmp, "nogh");
|
||||
fs.mkdirSync(noGh);
|
||||
const onPath = (tool: string) => (process.env.PATH ?? "").split(path.delimiter).map(d => path.join(d, tool)).find(p => fs.existsSync(p));
|
||||
const execPath = spawnSync("git", ["--exec-path"], { encoding: "utf8", timeout: 30_000 }).stdout.trim();
|
||||
const realGit = [path.join(execPath, "git"), onPath("git")].find(p => p && fs.existsSync(p))!;
|
||||
fs.symlinkSync(fs.realpathSync(realGit), path.join(noGh, "git"));
|
||||
for (const tool of ["bash", "sh", "env", "du", "awk", "sed", "sort", "head", "find", "xargs", "stat", "date", "mktemp", "grep", "rm", "cat", "dirname", "basename", "uname", "tr", "cut", "wc"]) {
|
||||
const found = onPath(tool);
|
||||
if (found) fs.symlinkSync(fs.realpathSync(found), path.join(noGh, tool));
|
||||
}
|
||||
const r = spawnSync("bash", [BIN, "--no-fetch"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: { ...process.env, PATH: noGh, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("PR column is -");
|
||||
const rows = r.stdout.split("\n").filter(Boolean).slice(2);
|
||||
expect(rows).toHaveLength(12);
|
||||
expect(rows.every(l => l.split("\t")[5] === "-")).toBe(true);
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\necho 'gh: not logged in' >&2; exit 4\n", { mode: 0o755 });
|
||||
const failing = audit("--no-fetch");
|
||||
expect(failing.status).toBe(0);
|
||||
expect(failing.stderr).toContain("gh could not list this repository's PRs; PR column is -");
|
||||
expect(failing.rows).toHaveLength(12);
|
||||
expect(failing.rows.every(row => row.PR === "-")).toBe(true);
|
||||
expect(audit("-b", "origin/nope", "--no-fetch").status).toBe(1);
|
||||
expect(audit("--no-fetch", path.join(tmp, "not-a-repo")).status).toBe(1);
|
||||
expect(audit("--bogus").status).toBe(2);
|
||||
expect(audit("--help").status).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user