Merge commit '1ccab0d8bccba50e69587672fb82ac0574638a65' into capy/gst-1088-lane-c

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

# Conflicts:
#	test/fixtures/skill-md-size-allowlist.json
#	test/helpers/carve-guards.ts
This commit is contained in:
garrytan committed 2026-11-03 15:46:50 +00:00
commit f092f83df0
267 files changed
+10123 -615

No files matched your search

+67 -5
View File
@@ -10,7 +10,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseCitations } from '../lib/approval-citations';
import { branchKey, buildRow, cell, importAutoplan, readRecord, renderRecord, type StoreLocation } from '../lib/approval-record';
import { APPROVALS_TABLE_HEADER, branchKey, buildRow, cell, importAutoplan, readRecord, renderRecord, type StoreLocation } from '../lib/approval-record';
const ROOT = path.resolve(import.meta.dir, '..');
const dirs: string[] = [];
@@ -52,7 +52,7 @@ describe('gstack-approvals add and render', () => {
expect(add.status).toBe(0);
expect(add.out).toMatch(/^APPROVAL_RECORDED: a-[0-9a-f]{12} .*\/projects\/acme-widgets\/approvals\/feature-capy-asks-[0-9a-f]{10}\.jsonl$/m);
expect(bin(ADD, { cwd, env: { GSTACK_STATE_ROOT: state } }).out).toContain('(already present)');
const r = bin(['render'], { cwd, env: { GSTACK_STATE_ROOT: state } });
const r = bin(['render', '--full'], { cwd, env: { GSTACK_STATE_ROOT: state } });
expect(r.status).toBe(0);
expect(r.out).toContain('## Approvals');
expect(r.out).toContain('| Date | Asked | Exact words | Outcome | Covers | Still pending |');
@@ -102,6 +102,68 @@ describe('gstack-approvals add and render', () => {
});
});
describe('public repositories get no identifying approval text (GST-1160)', () => {
const fakeGh = (out: string, code = 0) => {
const d = tmp('appr-gh-');
fs.writeFileSync(path.join(d, 'gh'), `#!/bin/sh\nprintf '%s\\n' '${out}'\nexit ${code}\n`, { mode: 0o755 });
return { PATH: `${d}:${process.env.PATH}` };
};
const QUOTE = ['add', '--ask', 'Approve the plan "Qm leak fix" (autoplan run 20261102-qm-77, 4 decisions)', '--words', 'do it, ship the leak fix', '--by', 'Garry (via QM Minder)', '--scope', 'the reviewed plan (sha256 9f2c1e0b7a6d5c4b3a29180716f5e4d3c2b1a0f9e8d7c6b5a4938271605f4e3d)'];
const leaks = (out: string) => ['do it, ship the leak fix', '20261102-qm-77', '9f2c1e0b7a6d', 'Garry', 'Qm leak fix'].filter(s => out.includes(s));
for (const [label, env] of [['public', () => fakeGh('PUBLIC')], ['unknown (gh fails)', () => fakeGh('', 1)]] as const) {
test(`a ${label} repository renders only that it was approved and an opaque record id`, () => {
const cwd = repo(); const state = tmp('appr-state-');
expect(bin(QUOTE, { cwd, env: { GSTACK_STATE_ROOT: state } }).status).toBe(0);
const r = bin(['render'], { cwd, env: { GSTACK_STATE_ROOT: state, ...env() } });
expect(r.status, r.err).toBe(0);
expect(leaks(r.out)).toEqual([]);
expect(r.out).toContain('## Approvals');
expect(r.out).toMatch(/^Approved: yes \(record r-[0-9a-f]{12}\)/m);
expect(r.out).not.toContain(APPROVALS_TABLE_HEADER);
expect(r.err).toContain('APPROVALS_RENDER: redacted');
});
}
test('a private repository keeps the full table, and --full is the explicit override when visibility is unknown', () => {
const cwd = repo(); const state = tmp('appr-state-');
expect(bin(QUOTE, { cwd, env: { GSTACK_STATE_ROOT: state } }).status).toBe(0);
const priv = bin(['render'], { cwd, env: { GSTACK_STATE_ROOT: state, ...fakeGh('PRIVATE') } });
expect(priv.out).toContain(APPROVALS_TABLE_HEADER);
expect(priv.out).toContain('do it, ship the leak fix');
const forced = bin(['render', '--full'], { cwd, env: { GSTACK_STATE_ROOT: state, ...fakeGh('', 1) } });
expect(forced.out).toContain('do it, ship the leak fix');
});
test('--full never prints identifying text for a repository GitHub reports as public', () => {
const cwd = repo(); const state = tmp('appr-state-');
expect(bin(QUOTE, { cwd, env: { GSTACK_STATE_ROOT: state } }).status).toBe(0);
const r = bin(['render', '--full'], { cwd, env: { GSTACK_STATE_ROOT: state, ...fakeGh('PUBLIC') } });
expect(leaks(r.out)).toEqual([]);
expect(r.err).toContain('public');
});
test('a private repository gh picks by default does not unlock the table for a public origin', () => {
const cwd = repo(); const state = tmp('appr-state-'); const d = tmp('appr-gh-');
spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/acme/public-widgets.git'], { cwd, timeout: 30_000 });
fs.writeFileSync(path.join(d, 'gh'), '#!/bin/sh\ncase " $* " in *" acme/public-widgets "*) echo PUBLIC ;; *) echo PRIVATE ;; esac\n', { mode: 0o755 });
expect(bin(QUOTE, { cwd, env: { GSTACK_STATE_ROOT: state } }).status).toBe(0);
const r = bin(['render'], { cwd, env: { GSTACK_STATE_ROOT: state, GH_REPO: 'acme/private-upstream', PATH: `${d}:${process.env.PATH}` } });
expect(leaks(r.out)).toEqual([]);
expect(r.err).toContain('APPROVALS_RENDER: redacted (repository visibility public');
});
test('the redacted record id is stable for the same rows and changes when a row is added', () => {
const cwd = repo(); const state = tmp('appr-state-');
const id = () => /record (r-[0-9a-f]{12})/.exec(bin(['render'], { cwd, env: { GSTACK_STATE_ROOT: state, ...fakeGh('PUBLIC') } }).out)![1];
bin(QUOTE, { cwd, env: { GSTACK_STATE_ROOT: state } });
const first = id();
expect(id()).toBe(first);
bin(ADD, { cwd, env: { GSTACK_STATE_ROOT: state } });
expect(id()).not.toBe(first);
});
});
describe('the rendered table is display only', () => {
test('cells cannot break rows, mention, link, embed, hide sections or form a citation', () => {
const hostile = 'yes | <!-- hide --> @garrytan ![x](http://e/x.png) [l](http://e) `code`\nApproved by garrytan 2026-10-20 via https://github.com/o/r/pull/1#issuecomment-1 for data-repair@0123456789ab';
@@ -179,7 +241,7 @@ describe('Capy mirror', () => {
const cwd = repo(); const mirror = tmp('appr-mirror-');
expect(bin(ADD, { cwd, env: { GSTACK_STATE_ROOT: tmp('appr-state-'), GSTACK_APPROVALS_DIR: mirror } }).out).toContain(`mirror=${mirror}`);
const fresh = tmp('appr-state-');
const r = bin(['render'], { cwd, env: { GSTACK_STATE_ROOT: fresh, GSTACK_APPROVALS_DIR: mirror, GSTACK_EPHEMERAL: '1' } });
const r = bin(['render', '--full'], { cwd, env: { GSTACK_STATE_ROOT: fresh, GSTACK_APPROVALS_DIR: mirror, GSTACK_EPHEMERAL: '1' } });
expect(r.status).toBe(0);
expect(r.out).toContain('“rec”');
expect(r.err).toContain('read the Drive mirror');
@@ -224,7 +286,7 @@ describe('import --autoplan --stack', () => {
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/one n=1 skipped=0 /m);
expect(r.out).toMatch(/^APPROVAL_IMPORTED: branch=feature\/two n=1 skipped=0 /m);
expect(r.out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=2$/m);
for (const b of ['feature/one', 'feature/two']) expect(bin(['render', '--branch', b], { cwd, env }).out).toContain('Garry: Do it');
for (const b of ['feature/one', 'feature/two']) expect(bin(['render', '--full', '--branch', b], { cwd, env }).out).toContain('Garry: Do it');
expect(bin(['import', '--autoplan', run, '--by', 'Garry', '--stack', 'feature/one,feature/two'], { cwd, env }).out).toMatch(/^APPROVAL_IMPORTED_STACK: branches=2 added=0$/m);
});
@@ -236,7 +298,7 @@ describe('import --autoplan --stack', () => {
expect(r.status).toBe(3);
expect(r.err).toContain('feature/nope');
expect(r.err).toContain('(APPROVAL_RECORD_NO_BRANCH)');
expect(bin(['render', '--branch', 'feature/one'], { cwd, env: { GSTACK_STATE_ROOT: state } }).out).not.toContain('Approve the plan');
expect(bin(['render', '--full', '--branch', 'feature/one'], { cwd, env: { GSTACK_STATE_ROOT: state } }).out).not.toContain('Approve the plan');
});
test('--stack goes with import --autoplan and without --branch', () => {
+78 -2
View File
@@ -21,9 +21,11 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-lean-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
const sha256 = (b: string | Buffer) => crypto.createHash('sha256').update(b).digest('hex');
const OFF_CAPY = { GSTACK_CAPY: '' };
const GIT_ISOLATED = { GIT_CONFIG_GLOBAL: path.join(TMP, 'empty-gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
fs.writeFileSync(GIT_ISOLATED.GIT_CONFIG_GLOBAL, '');
function git(cwd: string, ...args: string[]): string {
const r = spawnSync('git', ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', timeout: 30_000 });
const r = spawnSync('git', ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...GIT_ISOLATED } });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout;
}
@@ -323,8 +325,9 @@ describe('which runs are lean', () => {
});
describe('draft capture edges', () => {
const isolated = { ...OFF_CAPY, ...GIT_ISOLATED };
const lean = (name: string, cwd: string, out = path.join(TMP, name, 'run'), base = 'main') => {
const r = run(['next', '--kind', 'feature', '--base', base, '--out', out, '--plan', plan(name), '--no-ui'], cwd);
const r = run(['next', '--kind', 'feature', '--base', base, '--out', out, '--plan', plan(name), '--no-ui'], cwd, isolated);
if (r.status !== 0) throw new Error(`${r.stderr}\n${r.stdout}`);
return { r, ps: readRun(out).phases.eng, prompt: fs.readFileSync(path.join(out, 'eng-native-prompt.md'), 'utf8') };
};
@@ -388,6 +391,79 @@ describe('draft capture edges', () => {
expect(diff).toContain('+export const attempts = new Map');
});
const FAKE_KEY = ['sk', 'ant', 'api03', 'C'.repeat(93) + 'AA'].join('-');
test('the whole .env family and other secret files are left out before they are read (GST-1164, #3914)', () => {
const cwd = repo('envrc', true);
const files = ['.envrc', '.env-production', '.env_local', 'config/credentials-prod.json', '.ssh/id_ed25519_sk', '_netrc', '.git-credentials', '.pgpass'];
for (const f of files) {
fs.mkdirSync(path.dirname(path.join(cwd, f)), { recursive: true });
fs.writeFileSync(path.join(cwd, f), `export ANTHROPIC_API_KEY=${FAKE_KEY}\n`);
}
const { ps, prompt } = lean('envrc', cwd);
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
for (const text of [diff, prompt]) {
expect(text).not.toContain(FAKE_KEY.slice(0, 20));
for (const f of files) expect(text).toContain(`# draft: untracked ${f} left out (its name looks like a secret)`);
}
expect(diff).toContain('+export const attempts = new Map');
});
test('a secret file committed or edited on the branch is left out of the tracked diff too', () => {
const cwd = repo('tracked-envrc', true);
fs.writeFileSync(path.join(cwd, '.envrc'), `export ANTHROPIC_API_KEY=${FAKE_KEY}\n`);
git(cwd, 'add', '.envrc');
git(cwd, 'commit', '-q', '-m', 'direnv');
fs.appendFileSync(path.join(cwd, '.envrc'), 'export MORE=1\n');
const { ps, prompt } = lean('tracked-envrc', cwd);
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
for (const text of [diff, prompt]) {
expect(text).not.toContain(FAKE_KEY.slice(0, 20));
expect(text).not.toContain('MORE=1');
expect(text).toContain('# draft: .envrc left out (its name looks like a secret)');
}
expect(ps.draft.files).toContain('.envrc');
expect(diff).toContain('+export const JITTER = 0.1;');
});
test('a secret file renamed on the branch stays out under both names', () => {
const cwd = repo('rename-envrc', false);
git(cwd, 'checkout', '-q', 'main');
fs.writeFileSync(path.join(cwd, '.envrc'), 'dotenv_if_exists marker-zz9-old\n');
fs.writeFileSync(path.join(cwd, '.pgpass'), 'db.example.com:5432:app:bot:marker-zz9-pg\n');
git(cwd, 'add', '.envrc', '.pgpass');
git(cwd, 'commit', '-q', '-m', 'direnv');
git(cwd, 'checkout', '-q', 'feature');
git(cwd, 'merge', '-q', 'main');
git(cwd, 'mv', '.envrc', '.envrc.local');
fs.appendFileSync(path.join(cwd, '.envrc.local'), 'dotenv_if_exists marker-zz9-new\n');
git(cwd, 'mv', '.pgpass', 'pg.txt');
git(cwd, 'mv', 'lib/worker.ts', 'lib/retry.ts');
const { ps, prompt } = lean('rename-envrc', cwd);
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
for (const text of [diff, prompt]) {
expect(text).not.toContain('marker-zz9');
expect(text).toContain('# draft: .envrc -> .envrc.local left out (a rename or copy of a file whose name looks like a secret)');
expect(text).toContain('# draft: .pgpass -> pg.txt left out (a rename or copy of a file whose name looks like a secret)');
}
expect(diff).toContain('rename from lib/worker.ts\nrename to lib/retry.ts');
});
test('a key inside an ordinary file is redacted, untracked or edited in the working tree', () => {
const cwd = repo('redact', true);
fs.writeFileSync(path.join(cwd, 'lib', 'client.ts'), `export const KEY = '${FAKE_KEY}';\n`);
fs.appendFileSync(path.join(cwd, 'lib', 'worker.ts'), `const fallback = '${FAKE_KEY}';\n`);
const { ps, prompt } = lean('redact', cwd);
const diff = fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'), 'utf8');
for (const text of [diff, prompt]) {
expect(text).not.toContain(FAKE_KEY.slice(0, 20));
expect(text).toMatch(/^\+export const KEY = '<REDACTED-[^>]+>';$/m);
expect(text).toMatch(/^\+const fallback = '<REDACTED-[^>]+>';$/m);
}
expect(diff).toContain('+export const JITTER = 0.1;');
expect(ps.draft.sha256).toBe(sha256(fs.readFileSync(path.join(ps.snapshot_dir, 'draft.diff'))));
});
test('the DRAFT line and the prompt name the checkout that was diffed', () => {
const cwd = repo('root', true);
const { r, ps, prompt } = lean('root', cwd);
+6 -4
View File
@@ -81,15 +81,17 @@ describe('/captain skill', () => {
expect(read('captain/templates/automation-flaky-pass.md')).toContain('never merge');
});
test('every copy of the plan-first exemptions names the two fix exemptions and the eng-only plan for every other fix', () => {
test('every copy of the plan-first exemptions names the two fix exemptions and the R65 no-review rule for other bug fixes', () => {
for (const rel of ['docs/captain.md', 'captain/SKILL.md', 'captain/templates/CREW.md', 'capy/skill/gstack/SKILL.md']) {
const text = read(rel).replace(/\s+/g, ' ');
expect(text, rel).toMatch(/S1 emergenc(?:y|ies) \(a? ?broken install or upgrade, a? ?red main, a security bug\)/);
expect(text, rel).toMatch(/already[- ]diagnosed,? (?:and )?audited problem list|problem list that was already diagnosed and audited/);
expect(text, rel).toContain('one-round, eng-only plan');
expect(text, rel).toContain('--kind fix');
expect(text, rel).toContain('--kind feature');
expect(text, rel).not.toContain('one-round, eng-only plan');
}
expect(read('docs/captain.md')).toContain('gstack-autoplan close --out <run dir> --phase ceo --deferred');
expect(read('captain/templates/brief.md').replace(/\s+/g, ' ')).toContain('gstack-autoplan close --out <run dir> --phase ceo --deferred');
expect(read('docs/captain.md')).toContain('AUTOPLAN: not needed (bug fix; R65)');
expect(read('captain/templates/brief.md').replace(/\s+/g, ' ')).toContain('--kind feature');
});
test('is generic: parameters, not one crew\'s codes, names or repository', () => {
+1 -1
View File
@@ -83,7 +83,7 @@ describe('Capy Captain flow', () => {
expect(imp.status, imp.stderr).toBe(0);
expect(imp.stdout).toMatch(/^APPROVAL_IMPORTED: n=1 skipped=0 /);
expect(bin('gstack-approvals', ['import', '--autoplan', out, '--by', 'Garry'], env, repo).stdout).toMatch(/^APPROVAL_IMPORTED: n=0 skipped=1 /);
const table = bin('gstack-approvals', ['render'], env, repo).stdout;
const table = bin('gstack-approvals', ['render', '--full'], env, repo).stdout;
expect(table).toContain('Approve the plan "Retry backoff for the webhook worker"');
expect(table).toContain(`“${reply}” (Garry, relayed by agent:GSTA-1) | approved |`);
expect(fs.readdirSync(path.join(mirror, 'acme-worker')).length).toBe(1);
+113
View File
@@ -0,0 +1,113 @@
/**
* lib/ci-carry.ts (GSTA-258 piece 1; design fixes A2, A3): CI's verified run at HEAD's exact, clean tree stands in
* for running a lane again. The dependencies are fakes, so nothing here reaches GitHub.
*/
import { describe, expect, test } from 'bun:test';
import { findCarry, shipLanesErrors, waitForRun } from '../lib/ci-carry';
import { validateConfig } from '../lib/pregate/config';
import { receiptSummaryLine, validateReceipt } from '../lib/ship-receipt';
import type { ApiJob, ApiRun, RunDeps, RunProfile, RunSource, TreeRecord } from '../lib/run-evidence';
const REPO = 'garrytan/gstack';
const HEAD = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
const NOW = new Date('2026-11-02T12:00:00Z');
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-tests'], artifact: 'tested-tree', os: 'Linux' };
const run = (id: number, over: Partial<ApiRun> = {}): ApiRun => ({
id, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: HEAD, path: '.github/workflows/free-tests.yml',
created_at: `2026-11-02T1${id % 10}:00:00Z`.replace('T1', 'T0'), updated_at: '2026-11-02T11:30:00Z', repository: { full_name: REPO }, head_repository: { full_name: REPO }, ...over,
});
const record = (id: number, over: Partial<TreeRecord> = {}): TreeRecord => ({ run_id: id, run_attempt: 1, event: 'workflow_dispatch', sha: HEAD, tree: TREE, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 40, os: 'Linux', ...over });
function source(runs: ApiRun[], records: Record<number, TreeRecord> = {}, jobs: ApiJob[] = [{ name: 'free-tests', conclusion: 'success', completed_at: '2026-11-02T11:30:00Z' }]): RunSource {
const deps: RunDeps = {
run: id => { const r = runs.find(x => x.id === id); if (!r) throw new Error('HTTP 404'); return r; },
runsForHead: (_wf, sha) => runs.filter(r => r.head_sha === sha),
jobs: () => jobs,
artifact: id => { if (!records[id]) throw new Error('no artifact'); return records[id]!; },
commitTree: sha => (sha === HEAD ? TREE : null),
};
return { repo: REPO, deps, profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW };
}
const clean = () => TREE;
const carry = (src: RunSource, over: Partial<Parameters<typeof findCarry>[1]> = {}) => findCarry(src, { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, ...over });
const why = (v: ReturnType<typeof findCarry>) => (v.ok ? 'ok' : v.reason);
describe('findCarry', () => {
// Value: protects=a green verified run at this exact tree carries, with a receipt naming it; fails_when=a verified run is ignored or its count is lost;
// why_new=GSTA-258 piece 1; seam=lib/run-evidence.ts verifyRun; no_claim=GitHub's API shape (ghRunDeps)
test('the newest verified run at HEAD carries, and the receipt names its run and tree', () => {
const v = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, inventory: () => ['test/a.test.ts', 'test/b.test.ts'] });
expect(v.ok).toBe(true);
if (!v.ok) return;
expect([v.tests, v.unit]).toEqual([2, 'files']);
expect(v.runs.map(e => e.run_id)).toEqual([4]);
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb files=2 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
const whole = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean });
expect(whole.ok && [whole.tests, whole.unit, whole.receipt.split(' ')[2]]).toEqual([40, undefined, 'tests=40']);
});
// Value: protects=A2, a carry binds to the working-tree fingerprint before and after; fails_when=a dirty tree or a tree edited during the lookup carries
test('a dirty working tree, or one that changes while runs are read, never carries', () => {
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => 'c'.repeat(40) }))).toBe('dirty_tree');
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => null }))).toBe('dirty_tree');
let reads = 0;
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => (reads++ ? 'c'.repeat(40) : TREE) }))).toBe('tree_changed');
});
// Value: protects=A3, a lane carries only what CI provably ran; fails_when=a tree.json missing an inventory file still carries
test('the inventory must be covered by the runs\' tree.json files', () => {
const src = source([run(4)], { 4: record(4) });
const v = carry(src, { inventory: () => ['test/a.test.ts', 'test/c.test.ts'] });
expect(why(v)).toBe('inventory_uncovered');
expect(v.ok ? '' : v.detail).toContain('test/c.test.ts');
expect(why(carry(src, { inventory: () => ({ error: 'exited 1' }) }))).toBe('inventory_failed');
expect(why(carry(src, { inventory: () => [] }))).toBe('inventory_empty');
});
// Value: protects=fork runs, newer red or running runs, stale runs and empty runs never carry; fails_when=any of them is accepted
test('no run, a fork run, a newer red or running run, a stale run and a run with no tests are refused', () => {
expect(why(carry(source([])))).toBe('no_run');
expect(why(carry(source([run(4, { head_repository: { full_name: 'evil/gstack' } })], { 4: record(4) })))).toBe('no_verified_run');
const red = carry(source([run(4), run(6, { conclusion: 'failure' })], { 4: record(4), 6: record(6) }));
expect(why(red)).toBe('no_verified_run');
expect(red.ok ? '' : red.detail).toContain('run 6: run_not_success; run 4: newer_run_vetoes');
expect(why(carry(source([run(4), run(6, { status: 'in_progress', conclusion: null })], { 4: record(4) })))).toBe('no_verified_run');
expect(why(carry(source([run(4)], { 4: record(4) }, [{ name: 'free-tests', conclusion: 'success', completed_at: '2026-10-30T00:00:00Z' }])))).toBe('no_verified_run');
expect(why(carry(source([run(4)], { 4: record(4, { files: [] }) })))).toBe('no_tests');
expect(why(carry(source([run(4)], { 4: record(4) }), { profiles: ['nope'] }))).toBe('no_profile');
});
});
describe('waitForRun', () => {
const clockAt = () => { let t = 0; return { clock: () => t, sleep: async (ms: number) => { t += ms; } }; };
// Value: protects=the bounded wait reports green, red with failing jobs, still running and none; fails_when=it waits forever or calls a red run green
test('green, red with its failing jobs, still running at the deadline, and none', async () => {
const o = (runs: ApiRun[], jobs?: ApiJob[]) => waitForRun(source(runs, {}, jobs).deps, { workflow: 'free-tests.yml', events: PROFILE.events, head: HEAD, timeoutMs: 60_000, intervalMs: 20_000, ...clockAt() });
expect((await o([run(4)])).state).toBe('green');
const red = await o([run(4, { conclusion: 'failure' })], [{ name: 'free-tests', conclusion: 'failure' }, { name: 'typecheck', conclusion: 'success' }]);
expect(red).toMatchObject({ state: 'red', failing: ['free-tests'] });
expect((await o([run(4, { status: 'in_progress', conclusion: null })])).state).toBe('running');
expect((await o([])).state).toBe('none');
});
});
describe('ship_lanes_from config and the ci_carried receipt field', () => {
// Value: protects=A3, the mapping names real profiles and one inventory command; fails_when=a typo or a label-shaped entry validates
test('ship_lanes_from validates its profiles and inventory', () => {
const profiles = { 'ci-reuse/free-tests': PROFILE };
expect(validateConfig({ run_profiles: profiles, ship_lanes_from: { 'bun run test': { profiles: ['ci-reuse/free-tests'], inventory: 'bun run list' } } })).toEqual([]);
expect(shipLanesErrors({ 'bun run test': { profiles: ['nope'], inventory: 'x' } }, ['ci-reuse/free-tests']).map(e => e.message)).toEqual(['"nope" is not a run_profiles name']);
expect(shipLanesErrors({ 'bun run test': { profiles: ['ci-reuse/free-tests'], inventory: 'a\nb' } }, ['ci-reuse/free-tests'])[0]!.path).toBe('ship_lanes_from["bun run test"].inventory');
expect(shipLanesErrors({ 'bun run test': { profiles: ['ci-reuse/free-tests'] , inventory: 'x', label: 'free' } }, ['ci-reuse/free-tests'])[0]!.message).toContain('unknown key');
expect(shipLanesErrors(['bun run test'], [])[0]!.path).toBe('ship_lanes_from');
});
// Value: protects=the receipt and its SHIP_RECEIPT line name the carried runs; fails_when=ci_carried is dropped or malformed entries validate
test('the receipt records ci_carried and its summary line names each run', () => {
const receipt = { schema_version: 1 as const, head: HEAD, base: 'main', ci_carried: [{ lane: 'free', run: 41, attempt: 2, tree: TREE }] };
expect(validateReceipt(receipt)).toEqual([]);
expect(receiptSummaryLine(receipt)).toContain('ci_carried=free:41/2');
expect(validateReceipt({ ...receipt, ci_carried: [{ lane: 'free', run: '41' }] }).length).toBeGreaterThan(0);
});
});
+22
View File
@@ -223,6 +223,28 @@ describe('gstack-diff-scope', () => {
expect(scope.SCOPE_AUTH).toBe('true');
});
// Value: protects=auth matches as a word or path segment, so docs about authors stay out of the gate class;
// fails_when=the auth match is a bare substring again (author-spec.md, AUTHORS, authority.ts raise SCOPE_AUTH);
// why_new=GST-1065 (author-spec.md raised a docs bundle to gate class with four full readers); seam=bin/gstack-diff-scope
for (const file of ['docs/author-spec.md', 'AUTHORS', 'lib/authority.ts', 'docs/authored-by.md', 'src/coauthors.ts', 'AUTHORS.md']) {
test(`${file} is not an auth path`, () => {
expect(runScope(createRepo([file])).SCOPE_AUTH).toBe('false');
});
}
// Value: protects=real auth paths still raise SCOPE_AUTH after the word-boundary fix (negative controls for GST-1065);
// fails_when=the boundary drops a segment, a separator, a camelCase name, an authn/authz/oauth spelling or a glued token (auth0, basicauth, nextauth);
// why_new=GST-1065 and its review (the first word-only matcher dropped glued tokens the old glob caught); seam=bin/gstack-diff-scope
for (const file of ['src/auth.ts', 'app/auth/login.rb', 'lib/oauth.ts', 'src/oauth2/token.ts', 'lib/authn/check.go', 'src/authz.py', 'src/authService.ts',
'src/useAuth.ts', 'lib/OAuthClient.ts', 'lib/authentication.rb', 'lib/authorization_policy.rb', 'src/Authorize.tsx', 'config/AUTH_KEYS.md', 'pkg/auth-middleware.go',
'lib/auth0.ts', 'src/basicauth.go', 'src/authguard.ts', 'lib/reauth.ts', 'pages/api/nextauth.ts', 'src/authtoken.ts', 'lib/preauth.rb', 'src/HTTPAuth.ts',
'src/unauthenticated.ts', 'lib/reauthentication.ts', 'src/deauthorize.ts', 'app/oauth2client.py', 'api/HTTPAuthorization.ts',
'src/nextauthmiddleware.ts', 'src/reauthn.ts', 'src/noauthrequired.ts']) {
test(`${file} is an auth path`, () => {
expect(runScope(createRepo([file])).SCOPE_AUTH).toBe('true');
});
}
test('detects config via bun.lock (Bun v1.2+ text lockfile)', () => {
const dir = createRepo(['bun.lock']);
const scope = runScope(dir);
+142
View File
@@ -0,0 +1,142 @@
/**
* gstack-evidence reuse and CI-sourced rows (GSTA-258 pieces 1-2, design fixes A4 and A5).
*
* run --reuse: a lane that passed at this exact working tree, command and execution identity never runs again;
* the newest matching row decides (a newer failure vetoes), the reused row keeps its original timestamp and the
* max age applies to it. record-ci: a lane CI carried is an explicit CI-sourced row for a run it verified itself, never a fictitious local run.
*/
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { findFilesBySuffix, gitIn } from './helpers/scratch-repo';
import { runBin } from './helpers/run-bin';
setDefaultTimeout(60_000);
const EVIDENCE = path.join(import.meta.dir, '..', 'bin', 'gstack-evidence');
let home: string;
let repo: string;
let counter: string;
const run = (args: string[]) => runBin(EVIDENCE, args, { cwd: repo, env: { GSTACK_HOME: home } });
const ledger = () => findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')[0]!;
const rows = (): any[] => fs.readFileSync(ledger(), 'utf8').trim().split('\n').map(l => JSON.parse(l));
const rewrite = (edit: (r: any[]) => any[]) => fs.writeFileSync(ledger(), edit(rows()).map(r => JSON.stringify(r)).join('\n') + '\n');
const executions = () => (fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').length : 0);
const lane = (pass: number, fail = 0) => `printf x >> '${counter}'; printf ' ${pass} pass\\n ${fail} fail\\nRan ${pass + fail} tests across 1 file. [1.00ms]\\n' >&2; exit ${fail ? 1 : 0}`;
const wtree = () => runBin(path.join(import.meta.dir, '..', 'bin', 'gstack-wtree'), [], { cwd: repo }).stdout.trim();
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-home-'));
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-repo-'));
counter = path.join(home, 'executions');
gitIn(repo, 'init -q -b main');
fs.writeFileSync(path.join(repo, 'src.txt'), 'v1\n');
gitIn(repo, 'add src.txt');
gitIn(repo, 'commit -q -m init');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
});
describe('gstack-evidence run --reuse (A5)', () => {
// Value: protects=a lane that passed at this tree never reruns, and the reuse is visible; fails_when=--reuse runs the suite again, or reports a pass it never recorded;
// why_new=GSTA-258 piece 2; seam=the ledger and the wtree fingerprint; no_claim=cross-machine reuse (CI carries that)
test('a pass at the same tree, command and identity is reused with its original timestamp and count', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
const first = rows().at(-1);
const again = run(['run', '--reuse', '--label', 'unit', '--', lane(4)]);
expect(again.status).toBe(0);
expect(executions()).toBe(1);
expect(again.stderr).toContain(`gstack-evidence: reused label=unit tree=${wtree().slice(0, 12)} from=${first.ts}`);
expect(again.stderr).toMatch(/gstack-evidence: recorded label=unit exit=0 tests_ran=4 log=\S+/);
const copy = rows().at(-1);
expect(copy.ts).toBe(first.ts);
expect(copy.reused_from).toBe(first.ts);
expect(copy.identity).toBe(first.identity);
expect(typeof first.identity).toBe('string');
expect(run(['check', '--label', 'unit']).stdout).toContain('EVIDENCE: FRESH label=unit exit=0 tests_ran=4');
});
// Value: protects=only the newest matching row decides; fails_when=an older pass is reused under a newer failure on the same tree
test('a newer failure on the same tree vetoes an older pass; without --reuse nothing is reused', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
expect(executions()).toBe(3);
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(5);
});
// Value: protects=reuse binds to the exact command, tree, count and identity; fails_when=a changed command, an edited file, a zero-run or unknown row, or another runtime reuses
test('another command, an edited tree, a zero-run or unknown count, or another identity runs again', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(2);
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(3);
rewrite(r => r.map(x => ({ ...x, tests_ran: 0 })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(4);
rewrite(r => r.map(x => ({ ...x, tests_ran: 'unknown' })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(5);
rewrite(r => r.map(x => ({ ...x, identity: 'f'.repeat(64) })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(6);
});
// Value: protects=the max age applies to the original pass, never to a refreshed copy; fails_when=reuse makes an old pass look new
test('a pass older than --max-age is not reused, and a reused copy keeps the age check honest', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 30 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(2);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 2 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--max-age', '1', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(3);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 3 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(3);
expect(run(['check', '--label', 'unit', '--max-age', '2']).stdout).toContain('EVIDENCE: STALE label=unit');
});
});
describe('gstack-evidence record-ci and carried (A4)', () => {
const ciRow = () => {
const tree = wtree();
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'], policy: 'origin/main@000000000000:.gstack/pregate.json' };
};
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source, and the receipt lists it only at its own tree;
// fails_when=a check cannot tell a CI row from a local run, or a stale carry from an earlier head reaches ci_carried
test('a CI-sourced row is FRESH for its exact command, and carried lists it only at its tree', () => {
expect(run(['run', '--label', 'unit', '--', lane(2)]).status).toBe(0);
fs.appendFileSync(ledger(), JSON.stringify(ciRow()) + '\n');
const chk = run(['check', '--label', 'free', '--expect-cmd', 'bun run test', '--max-age', '24']);
expect(chk.status).toBe(0);
expect(chk.stdout).toContain('EVIDENCE: FRESH label=free exit=0 tests_ran=900');
expect(chk.stdout).toContain('source=ci runs=41/2');
expect(JSON.parse(run(['carried', '--base', 'main', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
expect(JSON.parse(run(['carried', '--base', 'release', '--json']).stdout)).toEqual([]);
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
gitIn(repo, 'commit -qam two');
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
});
// Value: protects=record-ci finds and verifies the CI run itself, so no caller supplies run ids, a test count or a tree (/ship pass 1 red-team#1);
// fails_when=the forged call from that finding writes a FRESH carried row; seam=no origin, so the base's ship_lanes_from is unreadable
test('record-ci takes no run facts from its caller and records nothing without a verified run', () => {
const forged = run(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', wtree(), '--tests', '999', '--finished', new Date().toISOString(), '--', 'bun run test']);
expect(forged.status).toBe(2);
expect(forged.stderr).toContain('usage: gstack-evidence record-ci --label <L> --base <base> -- <command>');
const unverified = run(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
expect(unverified.status).toBe(1);
expect(unverified.stderr).toMatch(/^CARRY_NONE: config_unavailable: /m);
expect(findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')).toEqual([]);
});
});
+1 -1
View File
@@ -872,7 +872,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+1 -1
View File
@@ -871,7 +871,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+1 -1
View File
@@ -878,7 +878,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+1 -1
View File
@@ -880,7 +880,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+1
View File
@@ -0,0 +1 @@
{"sha":"fbe52f3a9e0f4442eb755f1747a95944b373b439bd05c61f3eb68345d1061a5b"}
+1
View File
@@ -0,0 +1 @@
{"sha":"aad667b5ea4c6d23d930dafffa38178c04be388cd4364fb73759fc92af17bb63"}
+1
View File
@@ -0,0 +1 @@
{"sha":"a7cc08fb31b6ca65932a316d589f0b09677426d193d63f88ec74fee6d93ce053"}
+1
View File
@@ -0,0 +1 @@
{"sha":"bd96406da28b03742384560425a333b6d6d7ed2be95bd2026c90e0039632cee2"}
+1
View File
@@ -0,0 +1 @@
{"sha":"6dd670ea040535ca6cceab81c29a395bb67ce5a2ab8fa2b81e51bc4711e4aada"}
@@ -0,0 +1 @@
{"sha":"1ebe47ddb3291a9e4fe90b5a260eb57e328144959549bc95c119b68d23652cb5"}
@@ -0,0 +1 @@
{"sha":"25050a7a61f81df649d1df0d0237dbf961671356395317c6a527741f5bb56877","replay":"GST-940"}
@@ -0,0 +1 @@
{"sha":"08464b78a723ee463ca3a28d0abbc4eb8f4f512192061c703743707b66b5a311","replay":"GST-942"}
@@ -0,0 +1 @@
{"sha":"a32963ef38d9d3613c58c805e8517cd2e593c63769b30dd7d40e2592fa48fc78","replay":"GSTA-224 H1"}
@@ -0,0 +1 @@
{"sha":"b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255","replay":"G1"}
@@ -0,0 +1 @@
{"sha":"78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5","replay":"GST-257"}
@@ -0,0 +1 @@
{"sha":"d6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08","replay":"#3638"}
@@ -0,0 +1 @@
{"sha":"0142d6bc3f2a5bceb9f8b2adef1d3a206e5836c99a0667fe995568da2e80a5a6","replay":"GST-508+GST-464+GST-967"}
@@ -0,0 +1 @@
{"sha":"de3e0d35f2af56492c42fd287b6868fbe19bce96b86321e74a4947ba909b74eb","replay":"GST-508+GST-464+GST-967"}
+1
View File
@@ -0,0 +1 @@
{"replay":"#3638","justification":"no-loss round 3: main restored the pre-#3621 reviewers (v1.91.331.0)"}
+1
View File
@@ -0,0 +1 @@
{"replay":"G1","justification":"GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-257","justification":"GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-464","justification":"GST-464 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md is byte-identical to the bodies at the L and GST-257 pins; only lead instructions outside the cut spans changed"}
@@ -0,0 +1 @@
{"replay":"GST-508+GST-464+GST-967","justification":"wave review-53cfb5bfe011 merge of main 68410828f into #3852: ship/sections/review-army.md and adversarial.md are main's GST-967 bytes plus exactly the GST-508+GST-464 lines (the merged file minus main equals the wave minus fc292e22f, line for line), so GST-967's dispatch-superset no-loss check and GST-508's no-loss replay both hold; merged-head checks in GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md"}
@@ -0,0 +1 @@
{"replay":"GST-508+GST-464","justification":"wave review-53cfb5bfe011 merge of #3744 into main: ship/sections/review-army.md and adversarial.md carry the GST-508 no-loss-replayed exclude pathspec placeholders inside the brief bodies (handoffs/GSTA-202/noloss-results.md) and the GST-464 lead-instruction edits outside the cut spans, which its brief-identity no-loss check admitted"}
@@ -0,0 +1 @@
{"replay":"GST-508+R51+GST-911+GST-967","justification":"wave review-53cfb5bfe011 merge of main 68410828f into #3852: lib/review-depth.ts is main's GST-967 bytes plus exactly the GST-508+R51+GST-911 lines (the merged file minus main equals the wave minus fc292e22f, line for line); GST-967's dispatch-superset no-loss check (test/review-depth.test.ts) passes on the merged file, and the GST-508+R51+GST-911 checks are rerun at the merged head (GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md)"}
@@ -0,0 +1 @@
{"replay":"GST-508+R51+GST-911","justification":"wave review-53cfb5bfe011 merge of main 3d99c2249 (#3848) into #3852: lib/review-depth.ts keeps GST-508's review_exclude_paths (no-loss replay, GStack Minder drive handoffs/GSTA-202/noloss-results.md), R51 with GST-850's dispatch-superset no-loss check, and GST-911's SVG drawings with GST-911-redos (no-loss replay: 0 of 100 main commits changed, reports/GSTA-249-911-decisions.tsv; test/review-planner-misreads.test.ts, test/review-risk-redos.test.ts); lib/review-risk.ts is main's GST-911 bytes. Merged-head checks (handoffs/GSTA-202/merge-3d99c22-checks.md): Arm P on unit 3593 differs only in the exclude pathspec and the disclosure line, the same brief diff as before the merge, and GST-911 panel unit f5cb6ec1e plans, dispatches and renders byte-identical briefs under 3d99c2249 and the merged head"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-508+R51","justification":"wave review-53cfb5bfe011 merge of #3744 into main: lib/review-depth.ts keeps both admitted changes, the GST-508 no-loss replay of review_exclude_paths (Arm V 33 of 41 blockers vs R 33 of 41, p_NI 0.0279; GStack Minder drive handoffs/GSTA-202/noloss-results.md) and the R51 risk-tier re-score with GST-850's dispatch-superset no-loss check: excluded paths sit beside the added and removed line maps, and the exclusion filter keeps the publishes-docs rule"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-508","justification":"GST-508 (#3744, GSTA-202) preregistered no-loss replay of review_exclude_paths at ef2760f: Arm V found 33 of 41 blockers vs R 33 of 41, exact sign flip with delta 4, p_NI 0.0279 (pass); Arm P identical depth, dispatch and reader sets on 26 of 26 units (GStack Minder drive handoffs/GSTA-202/noloss-results.md)"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-850","justification":"GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (wider security and money classes, write-up text in the batched diff) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-risk-linear.test.ts); reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-898","justification":"GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-911-redos","justification":"GST-911 ReDoS fix verdict-identity no-loss check (wave review-e7b551ebd865): SVG_REFUSED's url() entry, rewritten to run in linear time, refuses exactly the strings #3838's pattern refused (test/review-risk-redos.test.ts: 60,000 seeded strings and the hand-picked edges, and every SVG blob in the repository's history, with zero differences); SVG_MARKUP now refuses a DOCTYPE naming any document but W3C's SVG DTD (test/review-planner-misreads.test.ts), which only keeps more SVGs code and so drops no reviewer; GST-911's no-loss replay holds unchanged; reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-911","justification":"GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-940","justification":"GST-940 verdict no-loss check: svgDrawing reads a style element's opening tag as quoted pairs with ASCII whitespace, walks its body over the raw text, refuses any & in it, reads comments as CSS and refuses a non-ASCII element or attribute name; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the GST-940 cases in test/review-planner-misreads.test.ts; rerun on the merge with main at 8f08cb76a (GST-967 merged), with the same results; rerun on the merge with main at 5f705f367 (#3852 and #3864 merged), with the same results"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-942","justification":"GST-942 dispatch-superset no-loss check: the rmSync rule no longer stops at the first ), a matching rm( rule is added, and both read a file's changed lines joined for a delete split across lines, so a recursive delete raises security on more diffs and never on fewer; over the last 100 first-parent main commits to 3d99c2249 (62,531 changed lines) the line rules match all 170 lines the old rule matched plus 19 more, and no commit changes depth, class or risk class; reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-967","justification":"GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GST-986","justification":"GST-986 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/adversarial.md is byte-identical to the bodies at the GST-257 pin; only the lead's Step 11 instruction naming the targeted record's path changed, outside the cut spans"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GSTA-224 H1","justification":"GSTA-224 H1 preregistered no-loss run: the PR-type checklist against main's checklist and a no-gstack arm (Opus 5.5, 81 calls), 5 behavior cases, 10 Round 7 application blockers and 2 clean controls; every bar passed (handoffs/GSTA-224/h1-noloss-results.md)"}
+1
View File
@@ -0,0 +1 @@
{"replay":"L","justification":"GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)"}
+1
View File
@@ -0,0 +1 @@
{"replay":"PR-a","justification":"GSTA-167 variant G Arm 4: structural plan identity at the 14 cohort heads and the free exact re-score of saved C, D and A' findings (parts 4a and 4b)"}
+1
View File
@@ -0,0 +1 @@
{"replay":"R51","justification":"R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged"}
+1
View File
@@ -0,0 +1 @@
SKILL_CONTRACT_UNMET investigate (contract v1): missing debug-report, repro-after-fix. Fix: debug-report: print the DEBUG REPORT with real values for Symptom, Root cause, Fix, Evidence; repro-after-fix: run the test or repro again after your last edit and show its result. If an item truly does not apply, run gstack-skill-contract waive <item> --reason "<why>" (this session only; counted as a miss for follow). This blocks once.
+9 -9
View File
@@ -6,52 +6,52 @@
"agy/gstack-plan-design-review": 109107,
"agy/gstack-plan-devex-review": 114948,
"agy/gstack-plan-eng-review": 143581,
"agy/gstack-review": 101234,
"agy/gstack-review": 104728,
"codex/gstack-autoplan": 108400,
"codex/gstack-plan-design-review": 110464,
"codex/gstack-plan-devex-review": 116456,
"codex/gstack-plan-eng-review": 144746,
"codex/gstack-review": 100418,
"codex/gstack-review": 104251,
"copilot/gstack-autoplan": 109100,
"copilot/gstack-plan-design-review": 109373,
"copilot/gstack-plan-devex-review": 114410,
"copilot/gstack-plan-eng-review": 143746,
"copilot/gstack-review": 100852,
"copilot/gstack-review": 104309,
"cursor/gstack-autoplan": 108332,
"cursor/gstack-plan-design-review": 107915,
"cursor/gstack-plan-devex-review": 113680,
"cursor/gstack-plan-eng-review": 142336,
"cursor/gstack-review": 119147,
"cursor/gstack-review": 122781,
"factory/gstack-autoplan": 110610,
"factory/gstack-plan-design-review": 111013,
"factory/gstack-plan-devex-review": 117043,
"factory/gstack-plan-eng-review": 145444,
"factory/gstack-review": 121525,
"factory/gstack-review": 126554,
"gbrain/gstack-autoplan": 108739,
"gbrain/gstack-plan-eng-review": 124963,
"gemini/gstack-autoplan": 108890,
"gemini/gstack-plan-design-review": 109201,
"gemini/gstack-plan-devex-review": 114238,
"gemini/gstack-plan-eng-review": 143579,
"gemini/gstack-review": 100679,
"gemini/gstack-review": 104131,
"hermes/gstack-autoplan": 108742,
"hermes/gstack-plan-eng-review": 124999,
"kiro/gstack-autoplan": 108106,
"kiro/gstack-plan-design-review": 107761,
"kiro/gstack-plan-devex-review": 113532,
"kiro/gstack-plan-eng-review": 142198,
"kiro/gstack-review": 118985,
"kiro/gstack-review": 122605,
"openclaw/gstack-autoplan": 108562,
"openclaw/gstack-plan-eng-review": 123478,
"opencode/gstack-autoplan": 109081,
"opencode/gstack-plan-design-review": 109229,
"opencode/gstack-plan-devex-review": 114288,
"opencode/gstack-plan-eng-review": 143605,
"opencode/gstack-review": 119734,
"opencode/gstack-review": 123403,
"slate/gstack-autoplan": 108219,
"slate/gstack-plan-design-review": 107838,
"slate/gstack-plan-devex-review": 113606,
"slate/gstack-plan-eng-review": 142267,
"slate/gstack-review": 119066
"slate/gstack-review": 122693
}
}
+28
View File
@@ -935,6 +935,34 @@ describe('scan', () => {
expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: main (not a repository)`);
expect(r.code).toBe(1); // a base that cannot be diffed is a failed target, never a silent clean scan
});
test.skipIf(!POSIX)('--changed main reads origin/main over a stale local main (GST-1036)', () => {
// Value: protects=design review scans only the branch's frontend files; fails_when=a bare --changed base diffs against the stale local main
// and scans a stylesheet only upstream changed; why_new=GST-1036 (ship review-army passes a bare <base>); seam=bin/gstack-design-detect.ts
const bare = path.join(SANDBOX, 'stale-origin.git');
const work = path.join(SANDBOX, 'stale-work');
const other = path.join(SANDBOX, 'stale-other');
const log = path.join(SANDBOX, 'argv-stale.log');
try {
git(SANDBOX, 'clone', '-q', '--bare', REPO, bare);
git(SANDBOX, 'clone', '-q', bare, work);
git(SANDBOX, 'clone', '-q', bare, other);
for (const d of [work, other]) { git(d, 'config', 'user.email', 't@example.com'); git(d, 'config', 'user.name', 't'); }
fs.writeFileSync(path.join(other, 'src', 'styles.css'), '.hero { color: blue }\n');
git(other, 'commit', '-qam', 'upstream');
git(other, 'push', '-q', 'origin', 'HEAD:main');
git(work, 'fetch', '-q', 'origin');
git(work, 'checkout', '-q', '-b', 'feature', 'origin/main');
fs.writeFileSync(path.join(work, 'src', 'components', 'Card.tsx'), 'export const Card = () => <p />;\n');
git(work, 'commit', '-qam', 'change');
const r = run(['scan', '--changed', 'main', '--format', 'gstack'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log }, cwd: work });
const argv = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]).argv as string[];
expect(argv.slice(2).map(a => path.relative(fs.realpathSync(work), a)).sort()).toEqual(['src/components/Card.tsx']);
expect(r.err).toContain('gstack-design-detect: local main is behind origin/main; base is origin/main');
} finally {
for (const p of [bare, work, other, log]) fs.rmSync(p, { recursive: true, force: true });
}
});
});
describe('design-review REPORT_DIR agrees with the allow-list', () => {
+53 -1
View File
@@ -91,7 +91,7 @@ describe('gstack-lease', () => {
const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
const help = spawnSync('bash', [LEASE, '--help'], { encoding: 'utf8', timeout: 30_000 });
expect(help.stdout).toContain('print `<pid> <age>m alive|dead|stale` for every lease');
expect(help.stdout).toContain('print `<pid> <age>m alive|idle|dead|stale` for every lease');
});
test('touch writes <git dir>/gstack-leases/<pid> for --pid and prunes leases older than 120 minutes', () => {
@@ -716,6 +716,58 @@ describe('gstack-lease', () => {
}
});
// GST-1205: gstack-skill-end releases the run gstack-skill-start opened, so a finished run stops holding the lease.
test('gstack-skill-start opens a run on the lease and gstack-skill-end with its SESSION_ID releases it; another holder still counts', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
dirs.push(home, state);
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
const leaseDir = path.join(state, 'leases');
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: leaseDir };
const holder = sleeper();
const start = spawnSync(START, ['--skill', 'ship', '--parent-pid', String(holder)], { encoding: 'utf8', cwd: home, env, timeout: 30_000 });
expect(start.status, start.stdout + start.stderr).toBe(0);
const sid = start.stdout.match(/^SESSION_ID: (\S+)$/m)?.[1] ?? '';
expect(sid.startsWith(`${holder}-`), sid).toBe(true);
expect(run(['live'], env).status).toBe(0);
const end = spawnSync(path.join(REPO, 'bin/gstack-skill-end'), ['--skill', 'ship', '--outcome', 'success', '--session-id', sid, '--tel-start', '0'], { encoding: 'utf8', cwd: home, env, timeout: 30_000 });
expect(end.status, end.stdout + end.stderr).toBe(0);
const after = run(['live'], env);
expect(after.status, after.stdout + after.stderr).toBe(1);
expect(run(['list'], env).stdout).toMatch(new RegExp(`^${holder} \\d+m idle$`, 'm'));
const other = sleeper();
expect(run(['touch', '--pid', String(other)], env).status).toBe(0);
expect(run(['live'], env).stdout).toMatch(new RegExp(`^${other} \\d+m sleep$`, 'm'));
});
test('GST-1205 review: a hold without --run survives a sibling run\'s release in either order; an unrecorded release changes nothing; an orphaned run ages out by itself', () => {
const { dir, leases } = checkout();
const live = (pid: number) => run(['live', '--root', dir]).stdout.includes(`${pid} `);
for (const order of [['hold', 'run'], ['run', 'hold']]) {
const p = sleeper();
for (const step of order) expect(run(['touch', '--root', dir, '--pid', String(p), ...(step === 'run' ? ['--run', `${p}-1-a`] : [])]).status).toBe(0);
expect(run(['release', '--root', dir, '--pid', String(p), '--run', `${p}-1-a`]).status).toBe(0);
expect(live(p), order.join('>')).toBe(true);
}
const legacy = sleeper();
expect(run(['touch', '--root', dir, '--pid', String(legacy)]).status).toBe(0);
expect(run(['release', '--root', dir, '--pid', String(legacy), '--run', `${legacy}-9-z`]).status).toBe(0);
expect(live(legacy)).toBe(true);
expect(run(['release', '--root', dir, '--pid', String(legacy)]).status).toBe(2);
expect(run(['release', '--root', dir, '--pid', 'x', '--run', 'y']).status).toBe(2);
const shared = sleeper();
expect(run(['touch', '--root', dir, '--pid', String(shared), '--run', `${shared}-1-old`]).status).toBe(0);
const file = path.join(leases, String(shared));
const old = Math.floor(Date.now() / 1000) - 121 * 60;
fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace(/^run (\S+) \d+$/m, `run $1 ${old}`));
expect(live(shared)).toBe(false);
expect(run(['touch', '--root', dir, '--pid', String(shared), '--run', `${shared}-2-new`]).status).toBe(0);
expect(live(shared)).toBe(true);
expect(run(['release', '--root', dir, '--pid', String(shared), '--run', `${shared}-2-new`]).status).toBe(0);
expect(live(shared)).toBe(false);
expect(fs.readFileSync(file, 'utf8')).not.toContain('-1-old');
});
test('gstack-skill-start reports a lease the held lock kept from being written, and still starts', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
+27 -1
View File
@@ -8,7 +8,7 @@ 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 { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy';
import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, STAMP_PLACEHOLDERS_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy';
import { fillDue } from '../lib/ship-queue-train';
import { cmpVersion, parseVersion } from '../lib/version-source';
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
@@ -63,6 +63,32 @@ describe('validatePolicy (schema + containment, pure)', () => {
expect(codes({ preregistration_shas: ['not-a-sha'] })).toEqual(['POLICY_CONTAINMENT:preregistration_shas[0]']);
});
// Value: protects=stamp_placeholders defaults to none, needs its reader floor, and admits only short one-line literals
// that are neither the {version} slot nor a version (GST-1026); fails_when=a non-default value validates without
// min_gstack_version, or a version, a slot, a multi-line or an oversized entry is accepted; seam=validatePolicy
test('stamp_placeholders: empty by default, needs its floor, and admits only short literal placeholders (GST-1026)', () => {
const codes = (p: unknown) => validatePolicy(p).map(e => `${e.code}:${e.path}`);
const at = (stamp_placeholders: unknown) => codes({ stamp_placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION });
expect(DEFAULT_POLICY.stamp_placeholders).toEqual([]);
expect(POLICY_KEY_INTRODUCED.stamp_placeholders).toBe(STAMP_PLACEHOLDERS_KEY_VERSION);
expect(codes({ stamp_placeholders: [] })).toEqual([]);
expect(codes({ stamp_placeholders: ['TBD'] })).toEqual(['POLICY_INVALID:stamp_placeholders']);
expect(minimumReader({ stamp_placeholders: ['TBD'] })).toBe(STAMP_PLACEHOLDERS_KEY_VERSION);
expect(at(['TBD', 'UNRELEASED'])).toEqual([]);
expect(at('TBD')).toEqual(['POLICY_INVALID:stamp_placeholders']);
expect(at([7])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
expect(at(Array.from({ length: 17 }, (_, i) => `P${i}`))).toEqual(['POLICY_CONTAINMENT:stamp_placeholders']);
expect(at(['x'.repeat(33)])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
expect(at(['x'.repeat(32)])).toEqual([]);
expect(at(['TB\nD'])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
expect(at([''])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
expect(at([' '])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']);
expect(at(['{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
expect(at(['next-{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
expect(at(['1.2.3.0'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
expect(at(['0.60.553'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']);
});
// Value: protects=bump never is refused without its floor (an older gstack names the cause) and next to any policy that stamps a version at every merge, reports its reader floor, and prints its Step 12 consequence; fails_when=never validates bare, or beside stamp-at-merge, train or changelog_fragments;
// why_new=GST-380 added the value to an older key, which the per-key floor check skips; seam=validatePolicy, minimumReader, shipConsequences; no_claim=Step 12's behavior (test/ship-release.test.ts)
test('bump never needs its floor and rules out every policy that stamps at merge', () => {
+56 -1
View File
@@ -53,6 +53,25 @@ describe('E18: a repository without a version source or CHANGELOG', () => {
expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none');
});
// Value: protects=a receipt write --version named in a repository with no version source keeps that version through a
// carry (GST-1030 review); fails_when=the carry refuses because HEAD has no readable version, or drops the version;
// seam=bin/gstack-ship-receipt cmdCarry
test('a carry keeps the version write --version named when HEAD has no version source (GST-1030)', () => {
const repo = fixture();
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', 'changelog only'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-unversioned');
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', '--version', '2.0.0', '--body', body]).status).toBe(EXIT.ok);
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'test', 'a.test.ts'), 'test a\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'a test-only change');
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(carried.out, carried.err).toContain('CARRY: carried');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('2.0.0');
});
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']);
@@ -225,12 +244,48 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit
const mine = "export const MINE_KEY_VERSION = '1.2.3.0';";
const before = `${mine}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n`;
const moved = (text: string) => ({ before, after: text, oldVersion: '1.2.4.0', newVersion: '1.2.5.0', templates });
const side = { fork: '1.2.3.0', added: new Set([mine]) };
const side = { fork: '1.2.3.0', added: new Set([mine]), at: new Set([1]) };
expect(stampOnlyText({ ...moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))), side })).toBe(true);
expect(stampOnlyText(moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))))).toBe(false);
expect(stampOnlyText({ ...moved(before.replace("BASE_KEY_VERSION = '1.2.3.0'", "BASE_KEY_VERSION = '1.2.5.0'")), side })).toBe(false);
});
// Value: protects=the carry reads the branch's own line by position and, with a kept VERSION claim, accepts only the
// branch's own row moved from the fork version (GST-979, GST-980); fails_when=stampOnlyText refuses every change at
// equal versions or takes a duplicated base line as the branch's; seam=lib/ship-receipt.ts stampOnlyText
test('stampOnlyText reads the branch line by position, and with a kept VERSION moves only the branch\'s own row (GST-979, GST-980)', () => {
const templates = ["_KEY_VERSION = '{version}'"];
const key = (v: string) => `export const MINE_KEY_VERSION = '${v}';`;
const before = `${key('1.2.3.0')}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n${key('1.2.3.0')}\n`;
const side = { fork: '1.2.3.0', added: new Set([key('1.2.3.0')]), at: new Set([3]) };
const lines = (a: string, c: string) => `${a}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n${c}\n`;
const change = (after: string, oldVersion: string, newVersion: string) => stampOnlyText({ before, after, oldVersion, newVersion, templates, side });
expect(change(lines(key('1.2.3.0'), key('1.2.5.0')), '1.2.4.0', '1.2.5.0')).toBe(true);
expect(change(lines(key('1.2.5.0'), key('1.2.3.0')), '1.2.4.0', '1.2.5.0')).toBe(false);
expect(change(lines(key('1.2.3.0'), key('1.2.4.0')), '1.2.4.0', '1.2.4.0')).toBe(true);
expect(change(lines(key('1.2.4.0'), key('1.2.3.0')), '1.2.4.0', '1.2.4.0')).toBe(false);
expect(stampOnlyText({ before, after: lines(key('1.2.3.0'), key('1.2.4.0')), oldVersion: '1.2.4.0', newVersion: '1.2.4.0', templates })).toBe(false);
});
// Value: protects=the carry classifier reads a declared placeholder moved to the new version on the branch's own row
// as a stamp, through the same predicate as finish and the tree receipt (GST-1026); fails_when=stampOnlyText ignores
// the policy's placeholders, or accepts an undeclared one or a base row; seam=lib/ship-receipt.ts stampOnlyText
test('stampOnlyText reads a declared placeholder moved on the branch\'s own row as a stamp, and nothing else (GST-1026)', () => {
const templates = ["since: '{version}'"];
const row = (v: string, id: string) => ` { since: '${v}', id: '${id}' },`;
const text = (...rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n');
const before = text(row('TBD', 'base'), row('TBD', 'mine'));
const side = { fork: '0.60.552.0', added: new Set([row('TBD', 'mine')]), at: new Set([3]) };
const change = (after: string, extra: Record<string, unknown> = {}) => stampOnlyText({ before, after, oldVersion: '0.60.552.0', newVersion: '0.60.553.0', templates, side, placeholders: ['TBD'], ...extra });
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')))).toBe(true);
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { oldVersion: '0.60.553.0' })).toBe(true);
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: [] })).toBe(false);
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: ['WIP'] })).toBe(false);
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { templates: [] })).toBe(false);
expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { side: undefined })).toBe(false);
expect(change(text(row('0.60.553.0', 'base'), row('TBD', 'mine')))).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: '' });
+17 -2
View File
@@ -216,14 +216,14 @@ describe('gstack-upgrade', () => {
// every session's lease names capy-server and the caller's own-ancestry
// exclusion used to drop a sibling's fresh lease: a hand-run upgrade moved the checkout.
describe('a lease held by a shared harness (capy-server) ancestor', () => {
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}) => {
const underHarness = (f: ReturnType<typeof fixture>, harness: string, ageMinutes: number, extra: Record<string, string> = {}, lease = 'bash "$1" touch --root "$2" --pid $$ || exit 9') => {
const bin = path.join(f.host.base, 'harness');
fs.mkdirSync(bin, { recursive: true });
const exe = path.join(bin, harness);
if (!fs.existsSync(exe)) fs.copyFileSync(fs.realpathSync(spawnSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8', timeout: 30_000 }).stdout.trim()), exe);
fs.chmodSync(exe, 0o755);
const script = [
'bash "$1" touch --root "$2" --pid $$ || exit 9',
lease,
'[ "$4" -gt 0 ] && "$5" -e "const t = Date.now() / 1000 - Number(process.argv[2]) * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "${GSTACK_LEASE_DIR:-$(git -C "$2" rev-parse --absolute-git-dir)/gstack-leases}/$$" "$4"',
'bash "$3"',
'echo "exit=$?"',
@@ -242,6 +242,21 @@ describe('gstack-upgrade', () => {
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
// GST-1205: a finished /ship run's lease used to block the upgrade for 120 minutes.
test('a lease whose runs all finished (gstack-lease release) no longer blocks; a sibling run still open on the same harness does', () => {
const touch = (id: string) => `bash "$1" touch --root "$2" --pid $$ --run "$$-${id}"`;
const release = (id: string) => `bash "$1" release --root "$2" --pid $$ --run "$$-${id}"`;
const f = fixture();
const open = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${touch('2-b')} && ${release('1-a')} || exit 9`);
expect(open.stdout, open.stdout + open.stderr).toContain('exit=3');
expect(open.stderr).toContain('(UPGRADE_BUSY)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
const finished = underHarness(f, 'capy-server', 0, {}, `${touch('1-a')} && ${release('1-a')} || exit 9`);
expect(finished.stdout, finished.stdout + finished.stderr).toContain('exit=0');
expect(finished.stderr).not.toContain('UPGRADE_BUSY');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('once that lease is 120 minutes old the upgrade proceeds', () => {
const f = fixture();
const r = underHarness(f, 'capy-server', 121);
+1 -1
View File
@@ -248,7 +248,7 @@ describe('GSTA-135: registered skills get used', () => {
}
const oc = register(opts(d, 'openclaw', { mode: 'all', skillsDir: path.join(d, 'oc') }));
const review = fs.readFileSync(path.join(oc.dest, 'gstack-review', 'SKILL.md'), 'utf8');
expect(descriptionOf(review)[0]).not.toMatch(/^Use /);
expect(descriptionOf(review)[0]).not.toMatch(/^Use for: /);
}, 120_000);
test('OpenClaw hybrid is the default: the task skills listed directly, the router for the rest; mode switches refresh and uninstall cleanly', () => {
+154 -2
View File
@@ -38,6 +38,13 @@ case "$1 $2" in
hooks=""; [ -d "$d/extensions/gstack-start" ] && [ "$acc" = true ] && [ "$en" != false ] && hooks='{"name":"before_prompt_build"}'
printf '{"plugin":{"status":"loaded","enabled":%s},"typedHooks":[%s]}\n' "$([ "$en" = false ] && echo false || echo true)" "$hooks" ;;
esac
case "$1 $2" in "config set"|"config unset")
j="$d/openclaw.json"; b="$d/fake-base.json"
[ -f "$b" ] || { if [ -f "$j" ]; then cp "$j" "$b"; else printf '{"meta":{"lastTouchedVersion":"fake"}}' > "$b"; fi; }
acc=false; grep -q '^plugins.entries.gstack-start.hooks.allowConversationAccess=true$' "$c" && acc=true
en=$(grep '^plugins.entries.gstack-start.enabled=' "$c" | tail -1 | cut -d= -f2); enj=null; [ -n "$en" ] && enj=$en
if grep -q '^plugins.entries.gstack-start' "$c"; then printf '%s,"plugins":{"entries":{"gstack-start":{"enabled":%s,"hooks":{"allowConversationAccess":%s}}}}}' "$(sed 's/}$//' "$b")" "$enj" "$acc" > "$j"; else cp "$b" "$j"; fi ;;
esac
`;
const FAKE_HERMES = String.raw`#!/bin/sh
d="$HERMES_HOME"
@@ -48,10 +55,19 @@ case "$1 $2" in
"plugins remove") rm -rf "$d/plugins/$3" "$d/fake-state" ;;
"plugins list") if [ -d "$d/plugins/gstack-start" ]; then printf '[{"name":"gstack-start","status":"%s"}]\n' "$(cat "$d/fake-state" 2>/dev/null || echo 'not enabled')"; else echo '[]'; fi ;;
esac
case "$1 $2" in "plugins enable"|"plugins disable"|"plugins remove")
y="$d/config.yaml"; b="$d/fake-base.yaml"
[ -f "$b" ] || { if [ -f "$y" ]; then cp "$y" "$b"; else : > "$b"; fi; }
s=$(cat "$d/fake-state" 2>/dev/null)
{ cat "$b"; [ -z "$s" ] || printf 'plugins:\n %s:\n - gstack-start\n' "$s"; } > "$y.t"; mv "$y.t" "$y" ;;
esac
`;
/** A configured harness: what each harness's own setup leaves (OpenClaw onboarding, hermes setup) before gstack runs. */
const HEALTHY = { openclaw: '{"agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-5-5"}}},"gateway":{"mode":"local"}}', hermes: 'model:\n default: claude-opus-5-5\n provider: anthropic\nterminal:\n backend: local\n' };
const CONFIG = { openclaw: 'openclaw.json', hermes: 'config.yaml' };
/** A temp home, state root and profile, with fake openclaw and hermes CLIs first on PATH. */
function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; env?: Record<string, string> } = {}) {
function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; fresh?: boolean; env?: Record<string, string> } = {}) {
const d = tmp();
const bin = path.join(d, 'bin');
fs.mkdirSync(bin);
@@ -59,11 +75,13 @@ function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; env?: Reco
fs.writeFileSync(path.join(bin, 'hermes'), FAKE_HERMES, { mode: 0o755 });
const profile = path.join(d, target === 'openclaw' ? 'oc' : 'he');
fs.mkdirSync(profile, { recursive: true });
const config = path.join(profile, CONFIG[target]);
if (!extra.fresh) fs.writeFileSync(config, HEALTHY[target]);
const env = { PATH: (extra.noCli ? '' : bin + path.delimiter) + '/usr/bin:/bin', ANTHROPIC_API_KEY: 'sekret', [target === 'openclaw' ? 'OPENCLAW_STATE_DIR' : 'HERMES_HOME']: profile, ...extra.env };
const o = (more: Partial<RegisterOptions> = {}): RegisterOptions => ({ target: TARGETS[target], checkout: ROOT, stateRoot: path.join(d, 'state'), env, home: path.join(d, 'home'), version: VERSION, ...more });
const root = path.join(profile, target === 'openclaw' ? 'extensions' : 'plugins', HOOK_ID);
const calls = () => fs.existsSync(path.join(profile, 'fake-calls')) ? fs.readFileSync(path.join(profile, 'fake-calls'), 'utf8').trim().split('\n') : [];
return { d, bin, profile, env, o, root, calls };
return { d, bin, profile, config, env, o, root, calls };
}
describe('start hook: the note and the plugin files', () => {
@@ -486,7 +504,140 @@ describe('start hook: teardown keeps what gstack did not write (fake CLI)', () =
}, 300_000);
});
describe('start hook: HARN-18 smoke defects (GST-778)', () => {
const offPath = (r: { env: Record<string, string> }) => ({ ...r.env, PATH: '/usr/bin:/bin' });
const cli = (r: { bin: string; env: Record<string, string> }, t: 'openclaw' | 'hermes', ...args: string[]) => spawnSync(path.join(r.bin, t), args, { env: r.env, encoding: 'utf8', timeout: 30_000 });
test('OPENCLAW_CONFIG_PATH naming the profile own openclaw.json, even through a symlink, keeps the hook', () => {
const r = rig('openclaw');
const link = path.join(tmp(), 'ln');
fs.symlinkSync(r.profile, link);
const viaEnv = register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(link, 'openclaw.json') } });
expect(viaEnv.hook).toMatchObject({ state: 'active', required: true });
const s = rig('openclaw');
expect(register({ ...s.o({ startHook: true }), env: { ...s.env, OPENCLAW_CONFIG_PATH: s.config } }).hook?.state).toBe('active');
}, 120_000);
test('a foreign OPENCLAW_CONFIG_PATH still turns an env opt-in off, and says so as a failure with the fix', () => {
const r = rig('openclaw');
const res = register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(r.d, 'elsewhere', 'openclaw.json') } });
expect(res.hook).toMatchObject({ state: 'off', required: true });
expect(res.hook?.detail).toContain('OPENCLAW_CONFIG_PATH points OpenClaw at ' + path.join(r.d, 'elsewhere', 'openclaw.json'));
expect(res.hook?.fix).toContain('unset OPENCLAW_CONFIG_PATH or set it to ' + r.config);
expect(r.calls()).toEqual([]);
expect(fs.existsSync(path.join(r.profile, 'extensions'))).toBe(false);
expect(readLedger(res.ledger)!.meta.start_hook).toBe('off');
expect(fs.readFileSync(r.config, 'utf8')).toBe(HEALTHY.openclaw);
const custom = register({ ...r.o({ skillsDir: path.join(r.d, 'custom', 'skills') }), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } });
expect(custom.hook).toMatchObject({ state: 'off', required: true });
expect(custom.hook?.fix).toContain('--no-start-hook');
expect(register({ ...r.o({ skillsDir: path.join(r.d, 'custom', 'skills'), startHook: false }), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } }).hook).toEqual({ state: 'off' });
}, 120_000);
test('a fresh harness with no config: turning the hook on creates no config, runs no harness command, and fails with the setup to run first', () => {
for (const t of ['openclaw', 'hermes'] as const) {
const r = rig(t, { fresh: true });
const res = register(r.o({ startHook: true }));
expect(res.hook).toMatchObject({ state: 'written', required: true });
expect(res.hook?.detail).toContain('no ' + (t === 'openclaw' ? 'OpenClaw' : 'Hermes') + ' config at ' + r.config);
expect(res.hook?.fix).toContain(t === 'openclaw' ? 'openclaw onboard' : 'hermes setup');
expect(fs.existsSync(r.config)).toBe(false);
expect(r.calls()).toEqual([]);
expect(fs.existsSync(path.join(r.profile, 'skills'))).toBe(true);
expect(readLedger(res.ledger)!.meta.hook_active).toBe('no');
const bare = rig(t, { fresh: true, noCli: true });
const noCli = register(bare.o({ startHook: true })).hook;
expect(noCli).toMatchObject({ state: 'written', required: true });
expect(noCli?.detail).toContain(t + ' is not on PATH');
expect(noCli?.fix).toContain(t === 'openclaw' ? 'openclaw onboard' : 'hermes setup');
expect(noCli?.fix).not.toContain(t === 'openclaw' ? 'config set' : 'plugins enable');
}
}, 300_000);
test('once the harness setup has written its config, the next refresh turns the hook on and changes only the plugin entry', () => {
const o = rig('openclaw', { fresh: true });
register(o.o({ startHook: true }));
fs.writeFileSync(o.config, HEALTHY.openclaw);
expect(register(o.o()).hook).toMatchObject({ state: 'active', retried: true });
const json = JSON.parse(fs.readFileSync(o.config, 'utf8'));
expect(json.plugins).toEqual({ entries: { [HOOK_ID]: { enabled: true, hooks: { allowConversationAccess: true } } } });
delete json.plugins;
expect(json).toEqual(JSON.parse(HEALTHY.openclaw));
expect(o.calls().filter(l => l.startsWith('config set')).map(l => l.split('|')[0])).toEqual(['config set plugins.entries.gstack-start.hooks.allowConversationAccess true', 'config set plugins.entries.gstack-start.enabled true']);
const h = rig('hermes', { fresh: true });
register(h.o({ startHook: true }));
fs.writeFileSync(h.config, HEALTHY.hermes);
expect(register(h.o()).hook).toMatchObject({ state: 'active', retried: true });
expect(fs.readFileSync(h.config, 'utf8')).toBe(HEALTHY.hermes + 'plugins:\n enabled:\n - gstack-start\n');
}, 300_000);
test('turning off or unregistering a hook that never turned on, in a harness with no config, creates no config', () => {
for (const t of ['openclaw', 'hermes'] as const) {
const r = rig(t, { fresh: true });
register(r.o({ startHook: true }));
if (t === 'openclaw') expect(register(r.o({ startHook: false })).hook?.state).toBe('off');
else unregister(r.o().stateRoot, ROOT, 'hermes', r.env);
expect(fs.existsSync(r.config)).toBe(false);
expect(r.calls()).toEqual([]);
expect(fs.existsSync(r.root)).toBe(false);
}
}, 300_000);
test('Hermes status without hermes on PATH (its own terminal tool) reads the hook from config.yaml', () => {
const r = rig('hermes');
expect(register(r.o({ startHook: true })).hook?.state).toBe('active');
const row = () => status(r.o().stateRoot, VERSION, offPath(r)).find(s => s.target === 'hermes')!.hook!;
expect(row()).toMatchObject({ state: 'active' });
expect(row().detail).toContain('hermes is not on PATH');
cli(r, 'hermes', 'plugins', 'disable', HOOK_ID);
expect(row().state).toBe('disabled');
fs.writeFileSync(r.config, HEALTHY.hermes);
expect(row().state).toBe('written');
fs.writeFileSync(r.config, 'plugins: [unclosed\n');
expect(row()).toMatchObject({ state: 'unknown' });
expect(row().detail).toContain('hermes is not on PATH');
}, 120_000);
test('OpenClaw status without openclaw on PATH is unknown when openclaw.json turns the hook on, disabled when the user turned it off', () => {
const r = rig('openclaw');
expect(register(r.o({ startHook: true })).hook?.state).toBe('active');
const row = () => status(r.o().stateRoot, VERSION, offPath(r)).find(s => s.target === 'openclaw')!.hook!;
expect(row()).toMatchObject({ state: 'unknown' });
expect(row().detail).toContain('openclaw is not on PATH');
cli(r, 'openclaw', 'config', 'set', 'plugins.entries.gstack-start.enabled', 'false');
expect(row().state).toBe('disabled');
expect(status(r.o().stateRoot, VERSION, r.env).find(s => s.target === 'openclaw')!.hook!.state).toBe('disabled');
}, 120_000);
});
describe.skipIf(process.platform === 'win32')('start hook through ./setup --host', () => {
test('OPENCLAW_CONFIG_PATH naming the profile own config keeps an env opt-in on; a foreign one exits 4 with the hook off', () => {
const f = makeFixture();
const src = makeSource(f, path.join(f.dir, 'gstack'));
const bin = path.join(f.dir, 'fakebin');
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 });
const profile = path.join(f.home, '.openclaw');
fs.mkdirSync(profile, { recursive: true });
fs.writeFileSync(path.join(profile, 'openclaw.json'), HEALTHY.openclaw);
const PATH = bin + path.delimiter + f.env.PATH;
const own = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(profile, 'openclaw.json') } });
expect(own.status, own.stdout + own.stderr).toBe(0);
expect(own.stdout).toContain('start hook: active');
const g = makeFixture();
const gsrc = makeSource(g, path.join(g.dir, 'gstack'));
fs.mkdirSync(path.join(g.home, '.openclaw'), { recursive: true });
fs.writeFileSync(path.join(g.home, '.openclaw', 'openclaw.json'), HEALTHY.openclaw);
const foreign = runSetup(g, path.join(gsrc, 'setup'), ['--host', 'openclaw'], { env: { PATH: bin + path.delimiter + g.env.PATH, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(g.dir, 'other', 'openclaw.json') } });
expect(foreign.status, foreign.stdout + foreign.stderr).toBe(4);
expect(foreign.stdout).toContain('start hook: off');
expect(foreign.stderr).toContain('(HARNESS_HOOK_INACTIVE; docs/troubleshooting.md#harness-hook-inactive)');
expect(foreign.stderr).toContain('OPENCLAW_CONFIG_PATH');
expect(fs.existsSync(path.join(g.home, '.openclaw', 'skills', 'gstack', 'SKILL.md'))).toBe(true);
}, 300_000);
test('exits 4 with the skills registered when the hook does not turn on, and the next run retries it', () => {
const f = makeFixture();
const src = makeSource(f, path.join(f.dir, 'gstack'));
@@ -495,6 +646,7 @@ describe.skipIf(process.platform === 'win32')('start hook through ./setup --host
fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 });
const profile = path.join(f.home, '.openclaw');
fs.mkdirSync(profile, { recursive: true });
fs.writeFileSync(path.join(profile, 'openclaw.json'), HEALTHY.openclaw);
fs.writeFileSync(path.join(profile, 'fake-fail'), '');
const PATH = bin + path.delimiter + f.env.PATH;
const first = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH, GSTACK_HARNESS_START_HOOK: '1' } });
+104 -5
View File
@@ -8,7 +8,7 @@
*/
import { describe, expect, test } from 'bun:test';
import * as path from 'node:path';
import { decide, formatDrift, harnessDrift, hasDrift, MANIFEST, readManifest, serializeManifest, workingHarnessFiles,
import { decide, formatDrift, harnessDrift, hasDrift, MANIFEST, PINS_DIR, readManifest, workingHarnessFiles, writeManifest,
type HarnessManifest } from '../scripts/bump-harness-version';
import { caseSeriesIdentitiesV2, globRegex, HARNESS_PATTERNS, HARNESS_VERSION, isCaseOwnedFile, skillDirs, stampTrialSeries, treeEntries,
type TreeEntry } from '../scripts/eval-trial-series';
@@ -18,12 +18,14 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import { LIVE_REGISTRY, type Registry } from '../scripts/eval-flake-rank';
import { matchGlob } from './helpers/test-selection';
import { mergePinEdits, type PinMerge } from './helpers/pin-merge-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
describe('HARNESS_VERSION guard', () => {
test('every pinned shared-harness file matches scripts/harness-version.json', () => {
expect(MANIFEST).toBe('scripts/harness-version.json');
expect(PINS_DIR).toBe('scripts/harness-pins');
const manifest = readManifest(ROOT);
expect(manifest.version).toBe(HARNESS_VERSION);
expect(manifest.patterns).toEqual([...HARNESS_PATTERNS]);
@@ -54,10 +56,107 @@ describe('HARNESS_VERSION guard', () => {
expect(() => decide(base, now, 'bump', 'short')).toThrow('reason');
});
test('the manifest serializes one entry per line, sorted, as valid JSON', () => {
const text = serializeManifest({ ...base, files: { 'z.ts': { blob: 'z', since: 1 }, 'a.ts': { blob: 'a', since: 1 } } });
expect(JSON.parse(text).files).toEqual({ 'a.ts': { blob: 'a', since: 1 }, 'z.ts': { blob: 'z', since: 1 } });
expect(text.split('\n').filter(line => line.includes('"blob"'))).toEqual([' "a.ts": {"blob":"a","since":1},', ' "z.ts": {"blob":"z","since":1}']);
const copyManifest = (into: string) => {
fs.cpSync(path.join(ROOT, MANIFEST), path.join(into, MANIFEST));
fs.cpSync(path.join(ROOT, PINS_DIR), path.join(into, PINS_DIR), { recursive: true });
};
test('the manifest writes one pin file per harness file under scripts/harness-pins and reads back unchanged (GST-1084)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-pins-'));
try {
copyManifest(dir);
expect(readManifest(dir)).toEqual(readManifest(ROOT));
const next: HarnessManifest = { version: 3, patterns: ['test/helpers/x/**'], files: { 'z.ts': { blob: 'z', since: 1 }, 'test/a.ts': { blob: 'a', since: 1, nonBehavioral: 'why' } } };
writeManifest(dir, next);
expect(readManifest(dir)).toEqual(next);
expect(fs.readFileSync(path.join(dir, MANIFEST), 'utf8')).toBe('{\n "version": 3,\n "patterns": [\n "test/helpers/x/**"\n ]\n}\n');
expect(fs.readFileSync(path.join(dir, PINS_DIR, 'test/a.ts.json'), 'utf8')).toBe('{"blob":"a","since":1,"nonBehavioral":"why"}\n');
expect(fs.existsSync(path.join(dir, PINS_DIR, 'test/helpers/eval-store.ts.json'))).toBe(false);
fs.writeFileSync(path.join(dir, PINS_DIR, 'z.ts.txt'), 'stray\n');
expect(() => readManifest(dir)).toThrow('every harness pin is <pinned path>.json');
fs.rmSync(path.join(dir, PINS_DIR, 'z.ts.txt'));
fs.writeFileSync(path.join(dir, PINS_DIR, 'z.ts.json'), '<<<<<<< ours\n');
expect(() => readManifest(dir)).toThrow(`${PINS_DIR}/z.ts.json: `);
fs.writeFileSync(path.join(dir, PINS_DIR, 'z.ts.json'), '{"blob":"z","since":1}\n');
fs.writeFileSync(path.join(dir, MANIFEST), JSON.stringify({ version: 3, patterns: [], files: {} }));
expect(() => readManifest(dir)).toThrow('holds only version and patterns');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// Value: protects=the one-file-per-pin layout still fails on every stale pin; fails_when=a pin with an old blob, a deleted pin
// or a pin for a file that is gone passes the guard; seam=readManifest and harnessDrift on the checkout's harness files
test('a stale, missing or orphaned harness pin fails the guard (GST-1084)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-stale-'));
try {
copyManifest(dir);
const live = readManifest(ROOT);
const current = workingHarnessFiles(ROOT, live.patterns);
const pin = (file: string) => path.join(dir, PINS_DIR, `${file}.json`);
fs.writeFileSync(pin('test/helpers/eval-store.ts'), `${JSON.stringify({ ...live.files['test/helpers/eval-store.ts'], blob: '0'.repeat(40) })}\n`);
fs.rmSync(pin('test/helpers/e2e-gate.ts'));
fs.writeFileSync(pin('test/helpers/gone.ts'), `${JSON.stringify({ blob: '1'.repeat(40), since: live.version })}\n`);
const drift = harnessDrift(readManifest(dir), current);
expect(drift).toEqual({ changed: ['test/helpers/eval-store.ts'], added: ['test/helpers/e2e-gate.ts'], removed: ['test/helpers/gone.ts'] });
expect(formatDrift(drift, live.version)).toContain(`Merge conflict in ${MANIFEST} or ${PINS_DIR}/: take the higher version, then rerun the script.`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// Value: protects=a --bump that fails partway (pins written, manifest version not) cannot pass the guard with behavior pooled into the
// old series; fails_when=a pin ahead of the manifest version reads as no drift; seam=writeManifest failing on a real write, then harnessDrift
test('a bump that fails partway leaves a pin ahead of the manifest version, and the guard reports it (GST-1084)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-partial-'));
try {
copyManifest(dir);
const live = readManifest(dir);
const current = workingHarnessFiles(ROOT, live.patterns);
const [first, second] = current.map(entry => entry.file);
const blocked = path.join(dir, PINS_DIR, `${second}.json`);
const original = fs.readFileSync(blocked, 'utf8');
fs.rmSync(blocked);
fs.mkdirSync(blocked);
expect(() => writeManifest(dir, decide(live, current, 'bump', 'behavior changed in the runner'))).toThrow();
fs.rmdirSync(blocked);
fs.writeFileSync(blocked, original);
const after = readManifest(dir);
expect(after.version).toBe(live.version);
expect(after.files[first!]!.since).toBe(live.version + 1);
expect(harnessDrift(after, current).changed).toEqual([first]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// Value: protects=PRs that record decisions for different harness files merge without a conflict (the one-line-per-file manifest
// conflicted on adjacent lines and on the trailing comma); fails_when=two non-behavioral decisions on adjacent pins conflict in a
// real merge, or two decisions on one pin merge silently; seam=writeManifest and git merge-tree --write-tree on two branches
test('non-behavioral decisions on two different harness files merge cleanly in a real git merge (GST-1084)', () => {
const live = readManifest(ROOT);
const record = (file: string, blob: string, reason: string) => (root: string) => {
const manifest = readManifest(root);
const files = workingHarnessFiles(ROOT, live.patterns).map(entry => entry.file === file ? { file, blob } : entry);
writeManifest(root, decide(manifest, files, 'non-behavioral', reason));
};
const floor = 'test/helpers/pty/runners/floor.ts', observation = 'test/helpers/pty/runners/observation.ts';
const merges: PinMerge[] = [];
try {
const clean = mergePinEdits(copyManifest, record(floor, 'a'.repeat(40), 'floor runner logging only'), record(observation, 'b'.repeat(40), 'observation runner comment only'));
merges.push(clean);
const conflict = mergePinEdits(copyManifest, record(floor, 'a'.repeat(40), 'floor runner logging only'), record(floor, 'c'.repeat(40), 'floor runner rename only'));
merges.push(conflict);
expect(clean.output).not.toContain('CONFLICT');
expect(clean.clean).toBe(true);
expect(JSON.parse(clean.read(`${PINS_DIR}/${floor}.json`))).toEqual({ blob: 'a'.repeat(40), since: live.version, nonBehavioral: 'floor runner logging only' });
expect(JSON.parse(clean.read(`${PINS_DIR}/${observation}.json`))).toEqual({ blob: 'b'.repeat(40), since: live.version, nonBehavioral: 'observation runner comment only' });
expect(clean.read(MANIFEST)).toBe(fs.readFileSync(path.join(ROOT, MANIFEST), 'utf8'));
expect(conflict.clean).toBe(false);
expect(conflict.output).toContain(`CONFLICT (content): Merge conflict in ${PINS_DIR}/${floor}.json`);
} finally {
for (const merge of merges) fs.rmSync(merge.root, { recursive: true, force: true });
}
});
});
+3 -3
View File
@@ -490,10 +490,10 @@ do not launch the downstream skill or open a browser.`,
gateAfterStop: undefined, // operational multi-STOP skill, like ship
},
behavioral: 'plan',
maxSkeletonBytes: 55_517, // GST-1088 lane C diet (R56 cut ledger modules/cuts.json: learnings search and capture, repo ownership, queue status; plus the blocker-proof rule and the two dispatch markers): lowered from 59,073 to measured, no headroom; measured 55,517 (2026-11-03). Before: GSTA-96 hoist (2026-10-26): lowered from 80_900 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 319 bytes; measured 58,754. Before: Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
maxSkeletonBytes: 60_014, // GST-1088 lane B (#3909, raised from 59,774 to measured, no headroom; owner ruling via GStack Minder 2026-11-03): CONTRACT_CHECKER_SHIPPED=true now that bin/gstack-skill-contract ships (#3912) renders the gstack-skill-contract check/waive sentence under the Done means list (renders/GSTA-256/plan-v3.md line 74, r2-d6 "Done-means list"), +240 bytes; lane C's diet is expected to bring /review back under 59,073; measured 60,014. Before: GST-1088 lane B (raised from 59,073 to measured, no headroom): the contract: frontmatter block and the F1 "Done means" list rendered from it at the top of the body, plus widened triggers; F1 is a gate-rev-2 recommendation on the plan Garry approved with "do it" on gate rev 1 (2026-11-03), contingent on his gate-rev-2 answer, and lane C's diet is expected to bring /review back under 59,073; measured 59,774. Before: GSTA-96 hoist (2026-10-26): lowered from 80_900 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 319 bytes; measured 58,754. Before: Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
maxSizeRatio: 1.2869, // GST-1088 lane C diet (same cuts and additions as maxSkeletonBytes): lowered from 1.3180 to measured, rounded up; measured 139,650 / 108,523 = 1.28682 (2026-11-03). Before: + GSTA-167 variant G part 1 (plan approved by Garry 2026-10-30, autoplan-20261030161754 "Do it", which names about +0.3k prompt tokens per voice): the native adversarial and outside-voice prompts carry the blocking floor and the Severity and Blocks lines, +537 bytes; measured 1.3180 (2026-10-30). Before: 1.3136. GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (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 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (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 132,199 / 108,523 = 1.2182 (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 132,258 / 108,523 = 1.2187 (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 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (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 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
maxSizeRatio: 1.3283, // + GST-1088 lane B (#3909, raised from 1.3261 to measured, no headroom; owner ruling via GStack Minder 2026-11-03): the gstack-skill-contract check/waive sentence that CONTRACT_CHECKER_SHIPPED=true renders (renders/GSTA-256/plan-v3.md line 74, r2-d6 "Done-means list"), +240 bytes; lane C's diet is expected to bring /review back under 1.3180; measured 144,147 / 108,523 = 1.32826, rounded up. Before: + GST-1088 lane B (raised from 1.3180 to measured, no headroom): the contract: frontmatter block and the F1 "Done means" list, contingent on Garry's gate-rev-2 answer; lane C's diet is expected to bring /review back under 1.3180; measured 143,907 / 108,523 = 1.3261. Before: + GSTA-167 variant G part 1 (plan approved by Garry 2026-10-30, autoplan-20261030161754 "Do it", which names about +0.3k prompt tokens per voice): the native adversarial and outside-voice prompts carry the blocking floor and the Severity and Blocks lines, +537 bytes; measured 1.3180 (2026-10-30). Before: 1.3136. GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (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 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (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 132,199 / 108,523 = 1.2182 (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 132,258 / 108,523 = 1.2187 (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 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (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 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
},
codex: {
skill: 'codex',
@@ -895,7 +895,7 @@ do not launch the downstream skill or open a browser.`,
},
behavioral: 'prompt',
maxSkeletonBytes: 30_210, // GSTA-96 hoist (2026-10-27): lowered from 52_000 by the shared preamble that moved to sections/preamble.md, keeping main's headroom of 377 bytes; measured 29,833 on the v1.91.273.0 merge. Before: D3 carve: Arguments table, Section index, Phase 1 call-site and update-index additions, five STOP pointers; measured 51,532 (2026-10-10). + pstack item 2(b)/8 (Garry 2026-10-26 "do it"): the blast-radius STOP pointer and index row plus the shared-premise red flag; measured 51,623 (2026-10-26), inside the existing cap.
minUnionBytes: 53_779, // GST-1088 lane C: lowered from 55,000 to measured for the R56 cuts in modules/cuts.json (learnings search, capture learnings, investigation log), which test/cut-ledger.test.ts keeps out; measured union 53,779 (2026-11-03). Before: D3 carve: skeleton + five recipes; measured union 61,513 (2026-10-10)
minUnionBytes: 55_000, // D3 carve: skeleton + five recipes; measured union 61,513 (2026-10-10)
mustContain: ['root cause', 'hypothes', 'Flake evidence', 'still_open', 'already_fixed', 'P(fail | regression)', 'the one fact it is safe because of'],
maxSizeRatio: 1.12, // the monolith's cap (parity-harness MONOLITH_INVARIANTS before the carve); measured 61,513 / 60,600 = 1.0151 (2026-10-10). + the blast-radius section (2.8 KB); measured 66,167 / 60,600 = 1.0919 (2026-10-26).
},
+50
View File
@@ -0,0 +1,50 @@
/**
* A fake gh for the CI-carry tests (GSTA-258): serves the Actions run, jobs, artifact and download reads
* lib/run-evidence.ts ghRunDeps makes from a world file, and answers every other gh call with nothing.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
export interface FakeWorld {
runs: Array<Record<string, unknown> & { id: number; run_attempt: number; head_sha: string; path: string }>;
/** Keyed `<run id>/<attempt>` for one attempt's jobs, else `<run id>` for every attempt. */
jobs: Record<string, Array<{ name: string; conclusion: string | null; completed_at?: string }>>;
trees: Record<string, Record<string, unknown>>;
/** Once `marker` exists, the next run read writes `content` to `file` (a working-tree edit inside a run recheck). */
editWhenArmed?: { marker: string; file: string; content: string };
}
/** The stub body for shipFixture's gh(): run this file with the world beside it. */
export const fakeGhScript = (worldFile: string) => `exec bun --no-env-file --config=/dev/null '${import.meta.path}' '${worldFile}' "$@"`;
if (import.meta.main) {
const [worldFile, ...a] = process.argv.slice(2);
const world: FakeWorld = fs.existsSync(worldFile!) ? JSON.parse(fs.readFileSync(worldFile!, 'utf8')) : { runs: [], jobs: {}, trees: {} };
const lines = (list: unknown[]) => process.stdout.write(list.map(x => JSON.stringify(x)).join('\n') + (list.length ? '\n' : ''));
if (a[0] === 'api') {
const jq = a.indexOf('--jq');
const p = a.filter((x, i) => i > 0 && x !== '--paginate' && i !== jq && i !== jq + 1)[0] ?? '';
const [route, query = ''] = p.split('?');
const q = new URLSearchParams(query);
let m: RegExpExecArray | null;
if ((m = /\/actions\/runs\/(\d+)$/.exec(route!))) {
const run = world.runs.find(r => r.id === Number(m![1]));
if (!run) { process.stderr.write('HTTP 404'); process.exit(1); }
const e = world.editWhenArmed;
if (e && fs.existsSync(e.marker)) fs.writeFileSync(e.file, e.content);
process.stdout.write(JSON.stringify(run));
} else if ((m = /\/actions\/workflows\/([^/]+)\/runs$/.exec(route!))) {
lines(world.runs.filter(r => r.head_sha === q.get('head_sha') && r.path === `.github/workflows/${m![1]}`));
} else if ((m = /\/actions\/runs\/(\d+)\/attempts\/(\d+)\/jobs$/.exec(route!))) {
lines(world.jobs[`${m[1]}/${m[2]}`] ?? world.jobs[m[1]!] ?? []);
} else if ((m = /\/actions\/runs\/(\d+)\/artifacts$/.exec(route!))) {
lines(world.trees[m[1]!] ? [{ name: q.get('name'), size_in_bytes: 200, expired: false }] : []);
} else { process.stderr.write(`fake gh: no route for ${p}`); process.exit(1); }
} else if (a[0] === 'run' && a[1] === 'download') {
const tree = world.trees[a[2]!];
if (!tree) process.exit(1);
const dir = a[a.indexOf('--dir') + 1]!;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'tree.json'), JSON.stringify(tree));
}
}
+2
View File
@@ -209,6 +209,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/remote-identity-vectors.json': ['test/bin-context-windows-slug.test.ts', 'test/gstack-slug-parity.test.ts', 'test/remote-identity-vectors.test.ts'],
'test/fixtures/review-depth/**': ['test/review-dispatch-compare.test.ts'],
'test/fixtures/review-ledger/**': ['test/review-dispatch.test.ts'],
'test/fixtures/review-pins/**': ['test/review-packets-removed.test.ts'],
'test/fixtures/review-risk/gbrain-evals-289.json': ['test/review-risk-routing.test.ts'],
'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'],
'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'],
@@ -240,6 +241,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/ship-queue/gbrain-stamp-pair.json': ['test/helpers/ship-queue-fixture.ts', 'test/tree-receipt-gbrain-pair.test.ts'],
'test/fixtures/ship-triage/**': ['test/ship-triage-labels.test.ts'],
'test/fixtures/skill-bin-drift-allowlist.json': ['test/skill-bin-drift.test.ts'],
'test/fixtures/skill-contract/**': ['test/skill-contract-stop-hook.test.ts', 'test/skill-contract.test.ts'],
'test/fixtures/skill-evals/**': ['test/skill-eval-checks.test.ts', 'test/skill-eval-tasks.test.ts'],
'test/fixtures/skill-md-size-allowlist.json': ['test/skill-md-size-guard.test.ts'],
'test/fixtures/state-matrix-6792/**': ['test/state-matrix.test.ts'],
+41
View File
@@ -0,0 +1,41 @@
/**
* Two independent edits to a pin store, merged for real (GST-1084). seed(root) writes the base tree, edits a and b
* each change it on their own branch, and `git merge-tree --write-tree` says whether the two merge without a
* textual conflict. The repository is fresh under os.tmpdir(), outside any checkout, and git runs under gitEnv() so
* no global hook or config reaches it; the caller removes root.
*/
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 { gitEnv } from './git-env';
export interface PinMerge { root: string; clean: boolean; output: string; read: (rel: string) => string }
export function mergePinEdits(seed: (root: string) => void, a: (root: string) => void, b: (root: string) => void): PinMerge {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-merge-'));
const git = (...args: string[]) => spawnSync('git', ['-c', 'user.name=Pin fixture', '-c', 'user.email=pins@gstack.test', '-c', 'commit.gpgsign=false',
'-c', 'core.autocrlf=false', ...args], { cwd: root, encoding: 'utf8', timeout: 30_000, env: gitEnv() });
const must = (...args: string[]) => {
const result = git(...args);
if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed (${result.status}): ${result.stderr}`);
return result.stdout;
};
const commit = (edit: (root: string) => void, message: string) => { edit(root); must('add', '-A'); must('commit', '-q', '--no-verify', '-m', message); };
let merged: ReturnType<typeof git>;
try {
must('init', '-q', '-b', 'base');
commit(seed, 'seed');
for (const [branch, edit] of [['a', a], ['b', b]] as const) {
must('checkout', '-q', '-b', branch, 'base');
commit(edit, branch);
}
merged = git('merge-tree', '--write-tree', 'a', 'b');
if (merged.status !== 0 && merged.status !== 1) throw new Error(`git merge-tree failed (${merged.status}): ${merged.stderr}`);
} catch (e) {
fs.rmSync(root, { recursive: true, force: true });
throw e;
}
const tree = merged.stdout.split('\n')[0]!;
return { root, clean: merged.status === 0, output: merged.stdout, read: rel => must('show', `${tree}:${rel}`) };
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Fixtures for the skill routing and contract tests (GST-1088, lane A): fixture
* skills with contract blocks (never the real skill templates, which lane B
* owns), a Claude Code transcript builder, and hook runners with an isolated
* state root and a scrubbed session-kind environment.
*/
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 { bunIsolationArgs } from '../../lib/bun-isolation';
export const ROOT = path.resolve(import.meta.dir, '..', '..');
export const ROUTE_HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'skill-route-hook.ts');
export const CONTRACT_HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'skill-contract-stop-hook.ts');
export const SKILLS: Record<string, string> = {
investigate: `---
name: investigate
description: Fixture copy for the contract tests. (gstack)
triggers:
- debug this
- fix this bug
- this test fails
contract:
version: 1
items:
- id: debug-report
kind: report_labels
heading: DEBUG REPORT
labels: [Symptom, Root cause, Fix, Evidence]
- id: repro-after-fix
kind: check_after_last_edit
---
# /investigate (fixture)
## How to work
Reproduce the failure before you change code, then fix it and prove the fix.
`,
review: `---
name: review
description: Fixture copy for the contract tests. (gstack)
triggers:
- review this pr
- check my diff
contract: { version: 1, items: [ { id: specialists, kind: selected_specialists_dispatched, marker: "Dispatching N specialists:" }, { id: adversarial, kind: agent_dispatch, marker: "Dispatching adversarial pass: native", match: "Think like an attacker and a chaos engineer" }, { id: report, kind: report_labels, heading: Pre-Landing Review, labels: [Gate edits] } ] }
---
# /review (fixture)
`,
qa: `---
name: qa
description: Fixture skill with triggers and no contract. (gstack)
triggers:
- qa test this
- find bugs on site
---
# /qa (fixture)
`,
broken: `---
name: broken
description: Fixture skill whose triggers are not a block list. (gstack)
triggers: debug this, fix this bug
---
`,
};
/** A fresh skills dir with the fixture skills (all of them unless named). */
export function skillsDir(names: string[] = Object.keys(SKILLS), extra: Record<string, string> = {}): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skills-'));
for (const [name, body] of Object.entries({ ...Object.fromEntries(names.map(n => [n, SKILLS[n]])), ...extra })) {
fs.mkdirSync(path.join(dir, name));
fs.writeFileSync(path.join(dir, name, 'SKILL.md'), body);
}
return dir;
}
const line = (o: Record<string, unknown>) => JSON.stringify({ sessionId: 'sess-1', isSidechain: false, cwd: '/repo', timestamp: '2026-11-02T00:00:00.000Z', ...o });
export const user = (text: string) => line({ type: 'user', message: { role: 'user', content: text } });
export const meta = (text: string) => line({ type: 'user', isMeta: true, message: { role: 'user', content: [{ type: 'text', text }] } });
export const say = (text: string) => line({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } });
export const tool = (id: string, name: string, input: Record<string, unknown>) =>
line({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } });
export const result = (id: string, text: string, isError = false) =>
line({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text, is_error: isError }] } });
export const slash = (name: string, args = '') => user(`<command-message>${name} is running…</command-message>\n<command-name>/${name}</command-name>\n<command-args>${args}</command-args>`);
export const DEBUG_REPORT = [
'DEBUG REPORT',
'════════════',
'Symptom: login returns 500 for users without a session',
'Root cause: auth.ts dereferences session.user before the null check',
'Fix: moved the null check above the dereference (src/auth.ts:42)',
'Evidence: bun test test/auth.test.ts passes, 12 of 12',
'Status: DONE',
].join('\n');
/** Load /investigate through the Skill tool, edit, answer with no status line: must be blocked (gate rev 2, J1). */
export const J1_TRANSCRIPT = [
user('the login test fails, fix it'),
tool('t1', 'Skill', { skill: 'investigate' }),
result('t1', 'Launching skill: investigate'),
meta('# /investigate ...skill body...'),
tool('t2', 'Edit', { file_path: 'src/auth.ts', old_string: 'a', new_string: 'b' }),
result('t2', 'The file src/auth.ts has been updated.'),
say('Fixed: the null check was missing in src/auth.ts.'),
];
/** An interactive /investigate across three turns; each turn's prefix is a Stop point that must not block. */
export const INTERACTIVE_TURNS: string[][] = [
[slash('investigate', 'login fails'), meta('# /investigate ...skill body...'), say('I will look into it. Can you paste the error you see?')],
[user('TypeError: cannot read properties of undefined (reading user)'),
tool('r1', 'Read', { file_path: 'src/auth.ts' }), result('r1', 'export function login() {}'),
tool('e1', 'Edit', { file_path: 'src/auth.ts', old_string: 'a', new_string: 'b' }), result('e1', 'updated'),
say('I moved the null check in src/auth.ts.\n\nShould I also add a regression test for the missing session?')],
[user('yes please'),
tool('e2', 'Write', { file_path: 'test/auth.test.ts', content: 'test()' }), result('e2', 'created'),
tool('b1', 'Bash', { command: 'bun test test/auth.test.ts' }), result('b1', '12 pass, 0 fail'),
say(DEBUG_REPORT)],
];
export const SCRUBBED_ENV_KEYS = ['CI', 'GITHUB_ACTIONS', 'GSTACK_HEADLESS', 'GSTACK_SESSION_KIND', 'OPENCLAW_SESSION', 'CONDUCTOR_WORKSPACE_PATH',
'CONDUCTOR_PORT', 'GSTACK_SKILL_ROUTING', 'GSTACK_SKILL_CONTRACTS', 'GSTACK_SKILL_ROUTE_HINT', 'GSTACK_HOME', 'GSTACK_STATE_DIR', 'CLAUDE_PLUGIN_DATA'];
/** process.env without session-kind or switch markers, as an interactive Claude Code session (CLAUDE_CODE_ENTRYPOINT=cli). */
export function hookEnv(stateRoot: string, skills: string, extra: Record<string, string> = {}): Record<string, string> {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !SCRUBBED_ENV_KEYS.includes(k)) env[k] = v;
return { ...env, CLAUDE_CODE_ENTRYPOINT: 'cli', GSTACK_STATE_ROOT: stateRoot, GSTACK_INSTALLED_SKILLS_DIR: skills, ...extra };
}
export interface HookRun { code: number | null; stdout: string; stderr: string }
export function runHook(hook: string, input: string, env: Record<string, string>): HookRun {
const r = spawnSync(process.execPath, [...bunIsolationArgs(), hook], { input, env, encoding: 'utf8', timeout: 20_000 });
return { code: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
export function tmpDir(prefix: string): string {
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
}
+18
View File
@@ -134,6 +134,24 @@ describe('install verdict keyed by (checkout, project): both twins', () => {
expect(future.detail).toContain('unsupported schema_version');
});
test('GST-1161: a review-voice install on a shared machine never shadows the verdict the session launched with', () => {
const sr = tmp();
const proj = project('evals');
const launched = writeVerdict(sr, { project: '', bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'autoplan' });
const env = { GSTACK_STATE_ROOT: sr, GSTACK_INSTALL_VERDICT: launched };
expect(both('autoplan', proj, env)).toMatchObject({ refuse: false, status: 'ok' });
writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'review-voice',
required: ['patch', 'runtime', 'helpers', 'state-root', 'privacy', 'cores', 'revision'], trailer: 'gstack: ok 9.9.9.9 for=review-voice' });
expect(both('autoplan', proj, env)).toMatchObject({ refuse: false, status: 'ok' });
const alone = both('autoplan', proj, { GSTACK_STATE_ROOT: sr });
expect(alone).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED' });
expect(alone.detail).toContain('certified for=review-voice');
const gone = both('autoplan', proj, { GSTACK_STATE_ROOT: sr, GSTACK_INSTALL_VERDICT: path.join(sr, 'gone.json') });
expect(gone.detail).toContain('certified for=review-voice');
writeVerdict(sr, { project: proj, schema_version: 3, for: 'review-voice' });
expect(both('autoplan', proj, env).detail).toContain('unsupported schema_version');
});
test('a running Bun other than the recorded one refuses with BUN_RESOLUTION_DRIFT and names both', () => {
const sr = tmp();
const a = project('a');
+98
View File
@@ -0,0 +1,98 @@
/**
* CI carry and the remote gate (GSTA-258 A): after a verified CI run carries part of the tests stage on a small
* machine, the files left over can all be ones the remote gate never runs (gstack's paid evals). The gate itself then
* reports tests=0 (PREGATE_REMOTE_PAID_ONLY) and `clear --run` refuses paid_only, so the obligation could never clear.
* The carried run clears it instead, through remote_gate_runs and its inventory check. The run API is a fake.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import type { PregateFile } from '../lib/headless-artifacts';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, runPregate, type RunCheck } from '../lib/pregate/index';
import type { ApiRun, RunProfile } from '../lib/run-evidence';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const LANE = 'pregate.json/remote_gate';
const NOW = new Date('2026-11-02T12:00:00Z');
const LOCAL_OS = process.platform === 'darwin' ? 'macOS' : 'Linux';
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['unit'], artifact: 'tested-tree', os: LOCAL_OS };
const PAID = 'test/nightly/retry-heavy.test.js';
const FREE = ['test/retry.test.js', 'test/util.test.js'];
function fixture(): FixtureRepo {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js' } });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n', 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = true;\n' }, 'touch two subjects');
return repo;
}
/** Run 100 of free-tests.yml at HEAD, green, its tree.json listing `files`; the gate's inventory is FREE and its skipped command names PAID. */
function runCheck(repo: FixtureRepo, files: string[]): RunCheck {
const head = repo.git('rev-parse', 'HEAD');
const api: ApiRun = { id: 100, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free-tests.yml', created_at: '2026-11-02T10:00:00Z', updated_at: '2026-11-02T10:30:00Z', repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
return {
repo: 'fx/fx', profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW,
gateRuns: { profiles: ['ci-reuse/free-tests'], inventory: 'INVENTORY', skipped: 'SKIPPED' },
inventory: (command, _tree, selected) => (command === 'SKIPPED' ? (selected ?? []).filter(f => f === PAID) : FREE),
deps: {
run: () => api, runsForHead: (_wf, sha) => (sha === head ? [api] : []), jobs: () => [{ name: 'unit', conclusion: 'success' }],
artifact: () => ({ run_id: 100, run_attempt: 1, event: 'workflow_dispatch', sha: head, head_sha: head, tree: repo.git('rev-parse', 'HEAD^{tree}'), os: [LOCAL_OS], files, tests: files.length * 4 }),
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
},
} as RunCheck;
}
async function carried(repo: FixtureRepo, check: RunCheck): Promise<{ file: PregateFile; progress: string[] }> {
const progress: string[] = [];
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: l => progress.push(l), cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
loaded.ctx.fromRun = { ref: '100', check };
return { file: await runPregate(loaded.ctx, { stage: 'tests' }), progress };
}
const gate = (file: PregateFile) => file.requires_remote?.find(r => r.lane === LANE);
describe.skipIf(process.platform === 'win32')('CI carry clears the remote gate on a small machine', () => {
// Value: protects=a carried run whose remainder is only gate-skipped (paid) files leaves no obligation nothing can clear;
// fails_when=the remainder stays a remote_gate obligation that the gate reports tests=0 for and clear --run refuses paid_only;
// why_new=GSTA-258 A, the PREGATE_REMOTE_PAID_ONLY dead end seen at 0fba5533 (112 paid files after run 41554380563); seam=runPregate with a fake run API
test('the carried run clears the gate when only skipped files remain, and the receipt counts them', async () => {
const repo = fixture();
const check = runCheck(repo, FREE);
const { file, progress } = await carried(repo, check);
const obligation = gate(file)!;
expect(obligation.tests).toEqual([PAID]);
expect(clearDecision({ ...file, requires_remote: [{ ...obligation, receipt: null }] }, LANE, { runs: ['100'], evidence: check }, repo.work)).toMatchObject({ refused: 'PREGATE_RECEIPT_INVALID', message: expect.stringContaining('reason=paid_only') });
expect([obligation.receipt, obligation.cleared_by]).toEqual([expect.stringMatching(new RegExp(`^remote-gate tree=${file.tree.slice(0, 12)} tests=8 skipped=1 runs=100/1 `)), 'ci-carry']);
expect(progress).toContain(`CI_CARRY: cleared ${LANE} ${obligation.receipt}`);
expect(file.verdict).toBe('requires-remote cleared');
}, 120_000);
// Value: protects=the carry clears the gate only under clear --run's own checks; fails_when=a run that misses an inventory file clears it
test('a run that misses a gate inventory file leaves the gate uncleared and says why', async () => {
const repo = fixture();
const { file, progress } = await carried(repo, runCheck(repo, ['test/util.test.js']));
expect(gate(file)?.receipt).toBeNull();
expect(progress).toContainEqual(expect.stringMatching(new RegExp(`^CI_CARRY: ${LANE} stays uncleared \\(REUSE: refused reason=inventory_uncovered`)));
}, 120_000);
// Value: protects=when the base declares remote_gate_runs, the uncleared gate names the CI route first and the VM only as an opt-in;
// fails_when=the obligation sends an agent to the paid VM while a CI run at the same tree would clear it; why_new=GSTA-258 A, plan choice F1
test('with remote_gate_runs on the base, an uncleared gate names the CI route first and the VM as the opt-in', async () => {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js', run_profiles: { 'ci-reuse/free-tests': PROFILE }, remote_gate_runs: { profiles: ['ci-reuse/free-tests'], inventory: 'echo test/retry.test.js' } } });
repos.push(repo);
commit(repo, { 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = 2;\n' }, 'touch retry');
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
expect(gate(file)?.receipt).toBeNull();
const detail = file.checks.find(c => c.id === 'lanes')?.detail ?? '';
expect(detail).toContain(`a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base main and rerun gstack-pregate --allow-repo-commands`);
expect(detail.indexOf('gstack-ci-carry wait')).toBeLessThan(detail.indexOf('opt in to the VM: gstack-pregate --remote --spend-cap <usd> --allow-repo-commands'));
loaded.ctx.ciCarry = false;
const off = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')?.detail ?? '';
expect(off).not.toContain('gstack-ci-carry wait');
expect(off).toContain('run it, then gstack-pregate clear --remote pregate.json/remote_gate');
}, 120_000);
});
+317
View File
@@ -0,0 +1,317 @@
/**
* gstack-pregate clear --flaky-exception (GST-1096): a red GitHub Actions run clears a requires-remote obligation only
* when every failure is named from the run's own shard results and logs, each named file is outside the PR's diff and
* its import closure, at most three cases, none paid or a security scan, and an owner's gstack-approvals row binds the
* repository, PR, head, tree, lane, run, attempt and cases. verify prints pass-with-exception, ready prints
* FLAKY_EXCEPTION and still needs green CI (pregate only), and the receipt records it. The run API is a fake; trees
* come from the fixture repository's git.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, type PregateFile } from '../lib/headless-artifacts';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, clearObligation, runPregate, verifyPregate, type ClearDecision, type RunCheck } from '../lib/pregate/index';
import { exceptionBinding, type FailureDeps, type GrantRow } from '../lib/pregate/flaky-exception';
import type { ApiJob, ApiRun, RunDeps, RunProfile } from '../lib/run-evidence';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
import { cleanup as cleanupQueue, makeFixtureRepo as makeQueueRepo, makePr, runBin as runQueueBin } from './helpers/restamp-fixture';
import { ghQueueShim } from './helpers/ship-queue-fixture';
import { shippedBody } from './helpers/ship-queue-train-fixture';
const repos: FixtureRepo[] = [];
const queueRepos: Array<ReturnType<typeof makeQueueRepo>> = [];
afterEach(() => {
while (repos.length) cleanup(repos.pop()!);
while (queueRepos.length) cleanupQueue(queueRepos.pop()!);
});
const LANE = 'pregate.json/remote_gate';
const NOW = new Date('2026-10-29T12:00:00Z');
const PROFILE_NAME = 'ci-reuse/free-tests';
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-plan', 'typecheck', 'free-tests'], artifact: 'tested-tree' };
const GATE_RUNS = { profiles: [PROFILE_NAME], inventory: 'true' };
const FLAKE = 'test/retry.test.js::retries three times';
const WORDS = 'yes, accept the retry timing flake for this head';
function fixture(): FixtureRepo {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js' } });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'the real change');
return repo;
}
async function pregateAt(repo: FixtureRepo): Promise<PregateFile> {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]);
return file;
}
interface Shard { files: string[]; failures?: Array<[string | null, string]>; status?: 'passed' | 'failed' | 'timed-out'; unattributed?: number; retry?: { files: string[]; failures: Array<[string, string]> }; log?: string | null }
interface RunOver { conclusion?: string; jobs?: (jobs: ApiJob[]) => ApiJob[]; missing?: string[]; pull?: { head_sha: string; state: string; head_repo: string | null }; newer?: boolean; attemptAfter?: number; api?: Partial<ApiRun> }
const DEFAULT_SHARDS: Shard[] = [
{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times']] },
{ files: ['test/b.test.ts', 'test/util.test.js', 'test/config-read.test.js', 'test/tool-cli.test.js'] },
];
function logFor(s: Shard, names: Array<[string | null, string]>): string {
const lines: string[] = [];
for (const f of s.files) {
lines.push(`${f}:`);
for (const [file, name] of names) if (file === f) lines.push(`(fail) ${name} [5005.00ms]`);
}
lines.push(`Ran ${s.files.length * 3} tests across ${s.files.length} files. [12.00s]`);
return lines.join('\n') + '\n';
}
/** Run 400: a red workflow_dispatch Free Tests run on `head` whose shard artifacts and logs say what failed. */
function redRun(repo: FixtureRepo, head: string, shards: Shard[] = DEFAULT_SHARDS, over: RunOver = {}): RunCheck & { failures: FailureDeps } {
const plan = { version: 1, revision: head, id: 'plan-1', shards: shards.map((s, i) => ({ shard: i + 1, files: s.files, predictedMs: 1000 })) };
const statusOf = (s: Shard) => s.status ?? (s.failures?.length || s.unattributed ? 'failed' : 'passed');
const finalFailed = (s: Shard) => statusOf(s) !== 'passed' && !(s.retry && s.retry.failures.length === 0);
const outcome = (s: Shard, i: number, files: string[], failures: Array<[string | null, string]>, status: string) => ({
shard: i + 1, files, status, exitCode: status === 'passed' ? 0 : 1, elapsedMs: 1000, groupPid: null,
summary: { testsRan: files.length * 3, filesRan: files.length, sawTerminalSummary: status !== 'timed-out' },
failingFiles: [...new Set(failures.map(([f]) => f).filter((f): f is string => !!f))], unattributedFailures: s.unattributed ?? 0,
failures: failures.map(([file, testName]) => ({ file, testName })),
});
const artifacts: Record<string, Record<string, string>> = { 'free-plan': { 'free-plan.json': JSON.stringify(plan) } };
shards.forEach((s, i) => {
const retry = s.retry ? outcome({ files: s.retry.files }, i, s.retry.files, s.retry.failures, s.retry.failures.length ? 'failed' : 'passed') : null;
artifacts[`free-result-${i + 1}`] = { [`shard-${i + 1}.json`]: JSON.stringify({ planId: 'plan-1', revision: head, outcome: outcome(s, i, s.files, s.failures ?? [], statusOf(s)), retry }) };
if (statusOf(s) !== 'passed' && s.log !== null) artifacts[`free-test-shard-logs-${i + 1}`] = { [`gstack-free-test-${i + 1}.log`]: s.log ?? logFor(s, [...(s.failures ?? []), ...(s.retry?.failures ?? [])]) };
});
for (const m of over.missing ?? []) delete artifacts[m];
const anyFailed = shards.some(finalFailed);
const at = '2026-10-29T10:30:00Z';
const baseJobs: ApiJob[] = [
{ name: 'reuse', conclusion: 'skipped', completed_at: at },
{ name: 'free-plan', conclusion: 'success', completed_at: at },
{ name: 'typecheck', conclusion: 'success', completed_at: at },
...shards.map((s, i) => ({ name: `free-suite (${i + 1})`, conclusion: finalFailed(s) ? 'failure' : 'success', completed_at: at })),
{ name: 'free-tests', conclusion: anyFailed ? 'failure' : 'success', completed_at: at },
];
const jobs = over.jobs ? over.jobs(baseJobs) : baseJobs;
let reads = 0;
const api = (): ApiRun => ({
id: 400, run_attempt: reads++ > 0 && over.attemptAfter ? over.attemptAfter : 1, status: 'completed', conclusion: over.conclusion ?? (anyFailed ? 'failure' : 'success'), event: 'workflow_dispatch', head_sha: head,
path: '.github/workflows/free-tests.yml', created_at: '2026-10-29T10:00:00Z', updated_at: at, repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' }, ...over.api,
});
const deps: RunDeps = {
run: id => { if (id !== 400) throw new Error('HTTP 404'); return api(); },
runsForHead: () => [{ ...api(), run_attempt: 1 }, ...(over.newer ? [{ ...api(), id: 401, run_attempt: 1, created_at: '2026-10-29T11:00:00Z', run_started_at: '2026-10-29T11:00:00Z' }] : [])],
jobs: () => jobs,
artifact: () => { throw new Error('run 400 has 0 unexpired artifacts named tested-tree'); },
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
};
const failures: FailureDeps = {
artifactFiles: (_id, name) => { const a = artifacts[name]; if (!a) throw new Error(`run 400 has 0 unexpired artifacts named ${name}`); return a; },
pull: () => over.pull ?? { head_sha: head, state: 'open', head_repo: 'fx/fx' },
};
return { repo: 'fx/fx', deps, profiles: { [PROFILE_NAME]: PROFILE }, gateRuns: GATE_RUNS, now: NOW, inventory: () => ['test/a.test.ts', 'test/b.test.ts'], failures };
}
const msg = (d: ClearDecision) => ('refused' in d ? `${d.refused}: ${d.message}${d.fix ? `; fix: ${d.fix}` : ''}` : `cleared ${d.receipt}`);
/** The scope the grant-needed refusal names, as an approvals row the owner granted. */
function grantFor(d: ClearDecision, over: Partial<GrantRow> = {}): (id: string) => GrantRow | null {
const scope = /--scope "(flaky-exception:[0-9a-f]{64})"/.exec(msg(d))?.[1];
if (!scope) throw new Error(`no grant scope in: ${msg(d)}`);
return id => (id === 'a-1' ? { id, outcome: 'approved', scope, words: WORDS, by: 'Garry', ...over } : null);
}
describe.skipIf(process.platform === 'win32')('clear --flaky-exception: an owner-granted exception for named flaky cases', () => {
test('a red run whose only failure is the named case clears the gate once the bound grant exists; verify reports pass-with-exception', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const check = redRun(repo, head);
const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work);
expect(msg(asked)).toMatch(/^PREGATE_RECEIPT_INVALID: FLAKY_EXCEPTION: refused reason=grant_needed — .*test\/retry\.test\.js::retries three times.*; fix: .*gstack-approvals add --ask '.*' --words "<the owner's exact words>" --by "<owner>" --scope "flaky-exception:[0-9a-f]{64}" --source pregate-flaky-exception/);
const d = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7, grant: 'a-1', grantRow: grantFor(asked) } }, repo.work);
if ('refused' in d) throw new Error(d.message);
expect(d.receipt).toBe(`remote-gate tree=${file.tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`);
expect(d.exception).toMatchObject({ lane: LANE, repo: 'fx/fx', pr: 7, head, tree: file.tree, runs: ['400/1'], cases: [FLAKE], grant: 'a-1', by: 'Garry', words: WORDS });
const cleared = clearObligation(file, LANE, d.receipt, 'test', d.substitution, d.exception);
expect(cleared.verdict).toBe('pass-with-exception');
expect(cleared.exceptions).toEqual([d.exception!]);
const v = verifyPregate(repo.work, cleared, { tree: file.tree } as any);
expect(v).toMatchObject({ ok: true });
expect(v.message).toContain(`flaky exception ${FLAKE} (grant a-1, by Garry, run 400/1)`);
const tampered = (e: Partial<NonNullable<PregateFile['exceptions']>[number]>) => verifyPregate(repo.work, { ...cleared, exceptions: [{ ...cleared.exceptions![0]!, ...e }] }, { tree: file.tree } as any);
expect(tampered({ head: 'f'.repeat(40) })).toMatchObject({ ok: false, code: 'PREGATE_REMOTE_UNCLEARED' });
expect(tampered({ cases: [FLAKE, 'test/b.test.ts::other'] }).message).toContain('binding does not match');
expect(verifyPregate(repo.work, { ...cleared, exceptions: [] }, { tree: file.tree } as any).message).toContain('no exceptions entry');
const plain = clearObligation(structuredClone(cleared), LANE, `remote-gate tree=${file.tree.slice(0, 12)} tests=18 runs=401/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`, 'test');
expect([plain.exceptions, plain.verdict]).toEqual([undefined, 'requires-remote cleared']);
}, 120_000);
test('the grant binds the head, PR, run and cases: another scope, an unapproved row, an agent\'s words or an unknown id refuse', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const check = redRun(repo, head);
const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work);
const run = (grantRow: (id: string) => GrantRow | null, grant = 'a-1') => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7, grant, grantRow } }, repo.work));
const otherHead = exceptionBinding({ lane: LANE, repo: 'fx/fx', pr: 7, head: 'e'.repeat(40), tree: file.tree, runs: ['400/1'], cases: [FLAKE] });
expect(run(grantFor(asked, { scope: otherHead }))).toContain('reason=grant_mismatch');
expect(run(grantFor(asked, { outcome: 'pending' }))).toContain('reason=grant_not_approved');
expect(run(grantFor(asked, { by: 'agent:jam_123' }))).toContain('reason=grant_not_owner');
expect(run(grantFor(asked), 'a-2')).toContain('reason=grant_missing');
}, 120_000);
test('every failure must be named: an extra failure, a case that did not fail, more than three cases, an unattributed failure refuse', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const clear = (check: RunCheck, cases = [FLAKE]) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases, pr: 7 } }, repo.work));
const two: Shard[] = [{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'other']] }, DEFAULT_SHARDS[1]!];
expect(clear(redRun(repo, head, two))).toContain('reason=unnamed_failure — run 400 attempt 1 failed test/a.test.ts::other, which no --flaky-exception names');
expect(clear(redRun(repo, head), [FLAKE, 'test/a.test.ts::passed fine'])).toContain('reason=case_not_failed — test/a.test.ts::passed fine did not fail in run 400');
expect(clear(redRun(repo, head), ['a.ts::1', 'b.ts::2', 'c.ts::3', 'd.ts::4'])).toBe('usage: --flaky-exception names at most 3 cases; got 4');
expect(clear(redRun(repo, head), ['no-separator'])).toContain('usage: --flaky-exception takes "<test file>::<case>"');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, unattributed: 1 }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1: 1 failure(s) the runner could not attribute to a test');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, failures: [[null, 'retries three times']] }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure');
expect(clear(redRun(repo, head, [{ files: DEFAULT_SHARDS[0]!.files }, DEFAULT_SHARDS[1]!]))).toContain('reason=run_green');
}, 120_000);
test('every job, shard and log is reconciled: a failed non-shard job, a missing result or log, a timed-out shard, a log failure the result omits refuse', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const clear = (check: RunCheck) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work));
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'failure' } : j) }))).toContain('reason=unnamed_failure — run 400 attempt 1\'s typecheck job concluded failure; only test-shard failures can be named');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => j.name === 'reuse' ? { ...j, conclusion: 'cancelled' } : j) }))).toContain('reason=unnamed_failure — run 400 attempt 1\'s reuse job concluded cancelled');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.filter(j => j.name !== 'free-suite (2)') }))).toContain('reason=shard_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { missing: ['free-result-2'] }))).toContain('reason=shard_incomplete — shard 2 has no readable free-result-2');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: null }, DEFAULT_SHARDS[1]!]))).toContain('reason=logs_missing — shard 1 failed and has no readable free-test-shard-logs-1');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, status: 'timed-out' }, DEFAULT_SHARDS[1]!]))).toContain('reason=shard_incomplete — shard 1 timed out');
const extraInLog = logFor(DEFAULT_SHARDS[0]!, [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'hidden']]);
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: extraInLog }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1\'s log names test/a.test.ts::hidden, which its result does not');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: logFor(DEFAULT_SHARDS[0]!, [['test/retry.test.js', 'retries three times']]) + '# Unhandled error between tests\n' }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1\'s log shows 1 unhandled error(s) between tests');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { conclusion: 'cancelled' }))).toContain('reason=run_not_failure');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { newer: true }))).toContain('reason=newer_run');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { attemptAfter: 2 }))).toContain('reason=run_changed');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { repository: { full_name: 'other/fx' } } }))).toContain('reason=other_repository');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { head_repository: { full_name: 'fork/fx' } } }))).toContain('reason=fork_run');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { path: '.github/workflows/evals.yml' } }))).toContain('reason=workflow_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { event: 'push' } }))).toContain('reason=event_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { status: 'in_progress' } }))).toContain('reason=run_in_progress');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => ({ ...j, completed_at: '2026-10-27T10:30:00Z' })) }))).toContain('reason=run_stale');
}, 120_000);
test('a red run that moves while the clear is assembled refuses, and the printed grant command quotes case names for the shell', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const check = redRun(repo, head);
const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work);
let rerun = false;
const run = check.deps.run;
const moving: RunCheck = { ...check, deps: { ...check.deps, run: id => (rerun ? { ...run(id), run_attempt: 2, status: 'in_progress' } : run(id)) }, inventory: () => { rerun = true; return ['test/a.test.ts', 'test/b.test.ts'] } };
expect(msg(clearDecision(file, LANE, { runs: ['400'], evidence: moving, flaky: { cases: [FLAKE], pr: 7, grant: 'a-1', grantRow: grantFor(asked) } }, repo.work))).toContain('reason=run_changed — run 400 moved to attempt 2 (in_progress)');
const name = "times out $(touch pwned) `id` it's";
const shard: Shard[] = [{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', name]] }, DEFAULT_SHARDS[1]!];
const fix = msg(clearDecision(file, LANE, { runs: ['400'], evidence: redRun(repo, head, shard), flaky: { cases: [`test/retry.test.js::${name}`], pr: 7 } }, repo.work));
const ask = /--ask ('(?:[^']|'\\'')*') --words/.exec(fix)?.[1];
expect(ask).toBeDefined();
const echoed = Bun.spawnSync(['bash', '-c', `printf %s ${ask}`], { cwd: repo.work, timeout: 10_000 });
expect(echoed.stdout.toString()).toContain(`test/retry.test.js::${name}`);
expect(fs.existsSync(path.join(repo.work, 'pwned'))).toBe(false);
}, 120_000);
test('a retry that passed is a recovered flake; a retry that failed again keeps only its own failures', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const shard1: Shard = { files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'recovered']], retry: { files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times']] } };
expect(msg(clearDecision(file, LANE, { runs: ['400'], evidence: redRun(repo, head, [shard1, DEFAULT_SHARDS[1]!]), flaky: { cases: [FLAKE], pr: 7 } }, repo.work))).toContain('reason=grant_needed');
}, 120_000);
test('a named file inside the PR diff or its import closure, a paid file or a security scan test refuses', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const one = (fileName: string, name: string) => {
const check = redRun(repo, head, [{ files: [fileName, 'test/retry.test.js', 'test/a.test.ts'].filter((f, i, a) => a.indexOf(f) === i), failures: [[fileName, name]] }, { files: DEFAULT_SHARDS[1]!.files.filter(f => f !== fileName) }]);
return msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [`${fileName}::${name}`], pr: 7 } }, repo.work));
};
expect(one('test/util.test.js', 'adds')).toContain('reason=case_in_closure — test/util.test.js depends on a file the PR changes (lib/util.js)');
expect(one('test/skill-e2e-flaky.test.ts', 'judge')).toContain('reason=case_paid — test/skill-e2e-flaky.test.ts is a paid test file');
expect(one('test/cso-docker-policy.test.ts', 'times out')).toContain('reason=case_security — test/cso-docker-policy.test.ts tests a security scan');
expect(one('test/ship-template-redaction.test.ts', 'blocks')).toContain('reason=case_security');
expect(one('browse/test/content-security.test.ts', 'blocks')).toContain('reason=case_security');
const touched = fixture();
commit(touched, { 'test/retry.test.js': "const test = require('node:test');\ntest('retries three times', () => {});\n" }, 'touch the flaky test');
const touchedHead = touched.git('rev-parse', 'HEAD');
const touchedFile = await pregateAt(touched);
expect(msg(clearDecision(touchedFile, LANE, { runs: ['400'], evidence: redRun(touched, touchedHead), flaky: { cases: [FLAKE], pr: 7 } }, touched.work))).toContain('reason=case_in_diff — test/retry.test.js is in the PR\'s diff');
}, 120_000);
test('the run must be at pregate.json\'s own head and tree, and the PR head must be that head: no rebinding', async () => {
const repo = fixture();
const older = repo.git('rev-parse', 'HEAD~1');
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const clear = (check: RunCheck) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work));
expect(clear(redRun(repo, older))).toContain('reason=head_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { pull: { head_sha: older, state: 'open', head_repo: 'fx/fx' } }))).toContain('reason=pr_mismatch — PR #7\'s head is');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { pull: { head_sha: head, state: 'closed', head_repo: 'fx/fx' } }))).toContain('reason=pr_mismatch — PR #7 is closed');
}, 120_000);
test('the bins: clear refuses --flaky-exception without --run or --pr; verify prints the PREGATE pass-with-exception line; the receipt records it', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const out = path.join(repo.work, '.gstack/tmp/pregate.json');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, JSON.stringify(file));
const noRun = runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, '--flaky-exception', FLAKE, '--pr', '7', '--out', out]);
expect([noRun.status, noRun.err]).toEqual([EXIT.usage, expect.stringContaining('--flaky-exception needs --run <id|url>')]);
const noPr = runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, '--run', '400', '--flaky-exception', FLAKE, '--out', out]);
expect([noPr.status, noPr.err]).toEqual([EXIT.usage, expect.stringContaining('--flaky-exception needs --pr <n>')]);
const exception = { lane: LANE, repo: 'fx/fx', pr: 7, head, tree: file.tree, runs: ['400/1'], cases: [FLAKE] };
const recorded = { ...exception, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(exception) };
const cleared = clearObligation(file, LANE, `remote-gate tree=${file.tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`, 'test', undefined, recorded);
fs.writeFileSync(out, JSON.stringify(cleared));
const v = runBin(repo, 'gstack-pregate', ['verify', '--out', out]);
expect([v.status, v.err]).toEqual([EXIT.ok, '']);
expect(v.out).toContain(`PREGATE: pass-with-exception (${FLAKE}; grant a-1)\n`);
const bound = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'interactive', '--pregate', out, '--json']);
expect([bound.status, bound.err]).toEqual([EXIT.ok, expect.any(String)]);
expect(bound.out).toContain('"flaky_exception":[{"lane":"pregate.json/remote_gate","runs":["400/1"],"cases":["test/retry.test.js::retries three times"],"grant":"a-1","by":"Garry"}]');
}, 180_000);
});
describe.skipIf(process.platform === 'win32')('ready with a flaky exception', () => {
test('ready accepts pass-with-exception, prints FLAKY_EXCEPTION, and still refuses a red required check (pregate only)', () => {
const repo = makeQueueRepo();
queueRepos.push(repo);
const head = makePr(repo, 'pr-a');
const tree = runQueueBin(repo, 'gstack-wtree', []).out.trim();
const exception = { lane: LANE, repo: 'o/r', pr: 1, head, tree, runs: ['400/1'], cases: [FLAKE] };
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({
schema_version: 1, tree, head, verdict: 'pass-with-exception', checks: [{ id: 'lanes', status: 'requires-remote' }],
requires_remote: [{ lane: LANE, platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: `remote-gate tree=${tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z` }],
exceptions: [{ ...exception, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(exception) }],
}));
const queue = (checks: unknown) => runQueueBin(repo, 'gstack-ship-queue', ['ready', '--pr', '1', '--pregate', pregate, '--base', 'main'], ghQueueShim(repo, { prs: { '1': { headRefOid: head, url: 'https://github.com/o/r/pull/1', body: shippedBody('1', head) } }, checks: { [head]: checks } }));
const ok = queue({ check_runs: [{ status: 'completed', conclusion: 'success' }] });
expect([ok.status, ok.err]).toEqual([EXIT.ok, '']);
expect(ok.out).toMatch(/^PREGATE: pass-with-exception tree=[0-9a-f]{12} /m);
expect(ok.out).toContain(`FLAKY_EXCEPTION: ${LANE} cases=${FLAKE} run=400/1 grant=a-1 by=Garry (pregate only; every required CI check still needs its own green run at this head)\n`);
const red = queue({ check_runs: [{ status: 'completed', conclusion: 'failure' }] });
expect(red.status).toBe(EXIT.refused);
expect(red.err).toMatch(/CI is not green at .*\(QUEUE_NOT_READY\)/);
const other = { ...exception, pr: 2 };
fs.writeFileSync(pregate, JSON.stringify({ ...JSON.parse(fs.readFileSync(pregate, 'utf8')), exceptions: [{ ...other, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(other) }] }));
const moved = queue({ check_runs: [{ status: 'completed', conclusion: 'success' }] });
expect(moved.status).toBe(EXIT.refused);
expect(moved.err).toContain('flaky exception grant a-1 is bound to PR #2');
}, 120_000);
});
@@ -54,6 +54,15 @@ describe('pregate secrets: source context and diff integrity', () => {
expect([...parseAddedLines(diff)]).toEqual([['a.ts', ['one', '++two']], ['b.env', ['three']]]);
});
test('a Python call that reads key.json is code; the same key name holding a literal key still fails (GST-1064)', () => {
expect(repo({ 'attach.py': 'import json\nkey = json.loads(files.get("key.json", ""))\n' }).secrets().status).toBe('pass');
const fake = 'Zx9Q' + 'w8Er7Ty6Ui5Op4As3Df2';
const r = repo({ 'attach.py': `key = json.loads(files.get("key.json")) or "${fake}"\n`, '.env': `KEY=${fake}\n` }).secrets();
expect(r.status).toBe('fail');
expect(r.lines.join('\n')).toContain('attach.py');
expect(r.lines.join('\n')).toContain('.env');
});
test('a TS object property that reads a credential at runtime is code, not a secret (#3211)', () => {
const f = repo({ 'app.ts': 'const cfg = {\n token: credentials.token,\n apiKey: settings.apiKey,\n};\n' });
expect(f.secrets().status).toBe('pass');
+37
View File
@@ -201,6 +201,16 @@ describe('literals (pure)', () => {
{ name: 'MAX', old: '3', new: '4', file: 'f.ts' }, { name: 'timeout', old: '5000', new: '9000', file: 'f.ts' },
]);
});
test('GST-1211: a key inside a newly added object literal is not a changed literal; an edited call-site value and an exported constant still are', () => {
const before = 'export const MAX = 3;\nconst page = { limit: 10 };\n';
expect(changedLiterals('f.ts', before, `${before}const query = { selection: { order: 'asc', limit: 300 } };\n`)).toEqual([]);
expect(changedLiterals('f.ts', before, 'export const MAX = 3;\nconst page = { limit: 20 };\n')).toEqual([{ name: 'limit', old: '10', new: '20', file: 'f.ts' }]);
expect(changedLiterals('f.ts', before, 'export const MAX = 4;\nconst page = { limit: 10 };\nconst query = { limit: 300 };\n')).toEqual([{ name: 'MAX', old: '3', new: '4', file: 'f.ts' }]);
expect(changedLiterals('f.ts', 'const retired = { limit: 10 };\nconst page = { limit: 20 };\n', 'const page = { limit: 30 };\n')).toEqual([
{ name: 'limit', old: '10', new: '30', file: 'f.ts' }, { name: 'limit', old: '20', new: '30', file: 'f.ts' },
]);
});
// Value: protects=a sibling file that shares the module's stem (`postgres-unit-arms.txt` beside `postgres-unit-arms.ts`) is not the
// module, so a test that only names it is not a consumer, while an extensionless import, a JS/TS import extension, the module's own
// file name and a path ending at the stem (then a quote, space, bracket, sentence end or `.d.ts`) still are, and neither `<file>.snap` nor a JS twin of a
@@ -519,6 +529,21 @@ describe('gstack-pregate (bin)', () => {
expect(w.out).toMatch(/^preflight {2}strays {4}warn .*downgraded to warn by origin\/main@/m);
});
test('GST-1211: probe.py and temporal-*.test.ts are not scratch names; zz-*, tmp*, scratch* and editor leftovers still are', () => {
const repo = fixture({ pregate: { ...BASE_PREGATE, warn: [] } });
commit(repo, { 'docs/comparison-systems/ext-temporal-graph/shipped-0.31/probe.py': 'print(1)\n', 'test/eval/temporal-graph-shipped.test.ts': 'export {};\n' });
const ok = pregate(repo, ['--stage', 'preflight', '--only', 'strays']);
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toMatch(/^preflight {2}strays {4}pass /m);
commit(repo, { 'docs/zz-notes.md': '# n\n', 'lib/tmp-x.ts': 'export {};\n', 'test/scratch.py': 'pass\n', 'lib/util.js.orig': 'x\n' });
const r = pregate(repo, ['--stage', 'preflight', '--only', 'strays']);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toMatch(/^preflight {2}strays {4}fail .*4 stray file\(s\)/m);
for (const f of ['docs/zz-notes.md', 'lib/tmp-x.ts', 'test/scratch.py', 'lib/util.js.orig']) expect(r.out).toContain(`strays: ${f}: scratch name (${f.split('/').pop()})`);
expect(r.out).not.toContain('probe.py: scratch name');
expect(r.out).not.toContain('temporal-graph-shipped.test.ts: scratch name');
});
test('p0-4-heavy-old-constant: a nightly-only test still asserting the old literal fails literals with file:line', () => {
const repo = fixture();
commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 9000;\nexports.retry = (fn) => fn();\n" });
@@ -532,6 +557,18 @@ describe('gstack-pregate (bin)', () => {
expect(pregate(repo, ['--stage', 'preflight', '--only', 'literals']).status).toBe(EXIT.ok);
});
test('GST-1211: a new object literal beside an existing `limit: 10` passes literals; editing that existing value still fails', () => {
const repo = fixture({ baseFiles: { 'lib/query.js': 'exports.page = () => ({ limit: 10 });\n', 'test/query.test.js': "const test = require('node:test');\nconst assert = require('node:assert');\nconst { page } = require('../lib/query.js');\ntest('pages by ten', () => assert.strictEqual(page().limit, 10));\n" } });
commit(repo, { 'lib/query.js': "exports.page = () => ({ limit: 10 });\nexports.selection = { order: 'asc', limit: 300 };\n" });
const ok = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).not.toContain('test/query.test.js');
commit(repo, { 'lib/query.js': "exports.page = () => ({ limit: 20 });\nexports.selection = { order: 'asc', limit: 300 };\n" });
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('literals: test/query.test.js:4: limit still 10 (now 20 in lib/query.js)');
});
// Value: protects=a real stale one-character constant still fails literals end to end after the #3492 narrowing, and an incidental
// digit in the same file is not what it reports; fails_when=the statement binding drops a named assertion; why_new=only
// multi-character values had a bin-level control; seam=none; no_claim=two-character values
+1 -1
View File
@@ -84,7 +84,7 @@ test('report-dir names the filesystem cause when .gstack is not a directory', ()
fs.writeFileSync(path.join(f.root, '.gstack'), 'not a directory\n');
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status).toBe(2);
expect(r.stderr).toContain('report-dir failed: ENOTDIR');
expect(r.stderr).toMatch(process.platform === 'win32' ? /report-dir failed: (ENOTDIR|ENOENT)/ : /report-dir failed: ENOTDIR/);
});
test('report-dir refuses outside a git work tree instead of guessing a location', () => {
+47
View File
@@ -106,6 +106,53 @@ describe("env.kv: a function call assigned to a credential name is code (#2899)"
);
});
describe("env.kv: a name-shaped literal inside a call is a file or dict key, not a secret (GST-1064)", () => {
const FAKE_KEY = "Zx9Qw8Er7Ty6Ui5Op4As3Df2";
const FAKE_STRIPE_TAIL = "4eC39HqLyjWDarjtT1zdp7dc";
table(
"env.kv",
"MEDIUM",
[
String.raw`key = json.loads(files.get("key.json", ""))`,
String.raw`token = resp.json().get("access_token")`,
String.raw` session = store.load('session.cookie')`,
String.raw`secret = vault.read("client_secret")`,
],
[
String.raw`key = "${FAKE_KEY}"`,
String.raw`KEY=${FAKE_KEY}`,
String.raw`key = json.loads("sk_live_${FAKE_STRIPE_TAIL}")`,
String.raw`token = resp.json().get("access_token", "${FAKE_KEY}")`,
String.raw`password = os.getenv("DB_PASSWORD", "hunter2.Zx9Qw8")`,
String.raw`key = json.loads(files.get("key.json")) or "${FAKE_KEY}"`,
String.raw`password = os.getenv("DB_PASSWORD", "correct-horse-battery-staple")`,
String.raw`api_key = decrypt("prod-signing-key")`,
String.raw`token = decrypt("correct_horse_battery_staple")`,
String.raw`key = decrypt("qwhzxkvbnmplor")`,
String.raw`SECRET_KEY = os.getenv("SECRET_KEY", "orange_monkey_pass")`,
String.raw`password = os.environ.get("DB_PASSWORD", "purple_walrus")`,
String.raw`token = resp.json().get("access_token", "orange_monkey_pass")`,
String.raw`key = derive_secret("tigers.horse")`,
String.raw`password = os.getenv("DB_PASSWORD", "qwhzxkvbnmplor.txt")`,
String.raw`password = make_password("orange_monkey_pass")`,
String.raw`password = bcrypt.hashpw("orange_monkey_pass", salt)`,
String.raw`token = jwt.encode("purple_walrus_pie", payload)`,
String.raw`api_key = settings.Secret("correct_horse_battery")`,
String.raw`password = hash_password("purple_walrus")`,
String.raw`password=hash_password("purple_walrus")`,
String.raw`+password = hash_password( "purple_walrus" )`,
String.raw`password = hash_password("purple_walrus").encode()`,
String.raw`password = hash_password("purple_walrus") # seed user`,
String.raw`password = config.get("db_password") or ("orange_monkey_pass")`,
String.raw`password = config.get("db_password", ("orange_monkey_pass"))`,
String.raw`password = login(user, ("purple_walrus"))`,
String.raw`password = load_cfg("app.yaml"); login("purple_walrus")`,
String.raw`password = login(user, str("purple_walrus"))`,
String.raw`password = encrypt(str("orange_monkey_pass"))`,
],
);
});
describe("internal.hostname: per-machine config filenames are not hosts (#2962, #2740)", () => {
table(
"internal.hostname",
+259
View File
@@ -14,6 +14,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy';
import { extractReceipt } from '../lib/ship-receipt';
import type { GitFixtureTemplate } from './helpers/git-fixture-template';
import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
@@ -329,4 +331,261 @@ describe('a branch-added row left at the fork version after the base moved (GST-
expect(receipt.out).toContain("id: 'base_row'");
expect(receipt.out).not.toContain("id: 'mine'");
});
// Value: protects=a base row whose text a branch row duplicates keeps the old-version floor in the tree receipt
// (GST-979, the #3862 outside voice); fails_when=the side matches by text, so a hand move of the base copy reads as
// same-modulo-stamps; seam=lib/stamp-only.ts branchSides positions
test('a base row whose text the branch duplicates still refuses a hand move; the branch copy\'s own move passes (GST-979)', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-dup-row-', policy: receiptPolicy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-dup');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', '', row('1.2.3.0', 'base_row')]));
repo.git('commit', '-qam', 'dup: the same row text at the fork version');
const gated = repo.git('rev-parse', 'HEAD');
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), '', '', row('1.2.5.0', 'base_row')]));
repo.git('commit', '-qam', 'stamp dup');
const own = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']);
expect(own.out, own.err).toContain('TREE: same-modulo-stamps');
write(repo, NOTICE, notice([row('1.2.5.0', 'base_row'), '', '', row('1.2.5.0', 'base_row')]));
repo.git('commit', '-qam', 'move the base copy by hand');
const hand = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']);
expect(hand.out, hand.err).toContain('TREE: changed');
});
// Value: protects=the receipt carry follows a stamp that moves the branch's own fork-version row, the move the writer
// makes since GST-963 (GST-980); fails_when=the carry keeps the branch-added row's bytes in both product patch-ids and
// refuses RECEIPT_CARRY_PRODUCT_CHANGED; seam=bin/gstack-ship-receipt carry walk
test('the carry follows a stamp that moves the branch\'s own fork-version row, and still refuses a product edit beside it (GST-980)', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-carry-row-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
fs.rmSync(path.join(repo.work, 'package-lock.json'));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-carry');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.3.0', 'mine')]));
repo.git('commit', '-qam', 'mine: claims 1.2.4.0 with a row at the fork version');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]);
expect(w.status, w.err).toBe(EXIT.ok);
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]));
repo.git('commit', '-qam', 'stamp 1.2.5.0');
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(carried.err).not.toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(carried.out, carried.err).toContain('CARRY: carried');
write(repo, 'src.ts', 'export const x = 1;\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'a product change after the stamp');
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');
});
/** The GST-980 carry state: a receipt written at the claim 1.2.4.0 with the branch's own row, then the stamp to 1.2.5.0, uncommitted. */
function carryAfterStamp(prefix: string, baseRows: string[], mine: string, stamped: string[]): { repo: FixtureRepo; body: string } {
const repo = makeFixtureRepo({ prefix, policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice(baseRows));
fs.rmSync(path.join(repo.work, 'package-lock.json'));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-carry');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
write(repo, NOTICE, notice([...baseRows, '', mine]));
repo.git('commit', '-qam', 'mine: a row of its own');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]);
expect(w.status, w.err).toBe(EXIT.ok);
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
expect(readFile(repo, NOTICE)).toBe(notice(stamped));
return { repo, body };
}
// Value: protects=the carry leaves out only a stamp on the branch's own rows, never a version edit on a base line of
// the same file (GST-980 review); fails_when=the carry trusts the whole-text version swap, so a hand move of a base
// line's version mention rides along with the row stamp; seam=bin/gstack-ship-receipt productSpec ownRows
test('the carry refuses a hand version edit on a base line of a stamp file whose own row it follows (GST-980)', () => {
const min = "export const MIN = '1.2.4.0';";
const { repo, body } = carryAfterStamp('restamp-carry-base-line-', [row('1.2.3.0', 'base_row'), min], row('1.2.4.0', 'mine'),
[row('1.2.3.0', 'base_row'), min, '', row('1.2.5.0', 'mine')]);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), min.replace('1.2.4.0', '1.2.5.0'), '', row('1.2.5.0', 'mine')]));
repo.git('commit', '-qam', 'stamp 1.2.5.0, and move the base pin by hand');
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(carried.status, carried.out).toBe(EXIT.refused);
expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
});
// Value: protects=a mode change rides on no stamp: the carry leaves out a stamp file only when its mode is the
// reviewed one (GST-980 review); fails_when=the left-out stamp file drops its mode line from both patch-ids;
// seam=bin/gstack-ship-receipt productSpec ownRows
test('the carry refuses a mode change on a stamp file whose own row it follows (GST-980)', () => {
const { repo, body } = carryAfterStamp('restamp-carry-mode-', [row('1.2.3.0', 'base_row')], row('1.2.3.0', 'mine'),
[row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]);
fs.chmodSync(path.join(repo.work, NOTICE), 0o755);
repo.git('add', '-A');
repo.git('update-index', '--chmod=+x', '--', NOTICE);
repo.git('commit', '-qm', 'stamp 1.2.5.0, and make the file executable');
expect(repo.git('ls-tree', 'HEAD', '--', NOTICE)).toStartWith('100755 ');
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(carried.status, carried.out).toBe(EXIT.refused);
expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
});
});
describe('declared placeholders and the receipt carry (GST-1026, GST-1030)', () => {
/** gbrain's policy (GST-1026): the since: template, and TBD declared as a placeholder when `declared`. */
const placeholderPolicy = (declared: boolean) => ({ ...receiptPolicy(true), ...(declared ? { stamp_placeholders: ['TBD'], min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) });
/** A branch row written as since: 'TBD' at the claim 1.2.4.0, then the stamp to 1.2.5.0 with the row restamped by hand (gbrain's release:restamp). */
function tbdStamp(prefix: string, pol: Record<string, unknown>, ownRow: string): { repo: FixtureRepo; gated: string } {
const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo);
write(repo, NOTICE, notice([row('TBD', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-tbd');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
write(repo, NOTICE, notice([row('TBD', 'base_row'), '', ownRow]));
repo.git('commit', '-qam', 'mine: a TBD row');
const gated = repo.git('rev-parse', 'HEAD');
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
return { repo, gated };
}
// Value: protects=the tree receipt (stamp, verify, gate-ahead replay) accepts gbrain's restamp of the branch's own
// since: 'TBD' row when the policy declares TBD, and refuses it undeclared, on a base row, or on a base row whose text
// the branch duplicates (GST-1026); fails_when=classifyFile reads the TBD move as a hand edit, or accepts it off the
// branch's own rows; seam=lib/tree-receipt.ts stampOnlyHunks
test('the tree receipt accepts a declared TBD row restamped on the branch, and refuses the same move on a base row (GST-1026)', () => {
const own = tbdStamp('restamp-tbd-receipt-', placeholderPolicy(true), row('TBD', 'mine'));
write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine')));
own.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row');
const accepted = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']);
expect(accepted.out, accepted.err).toContain('TREE: same-modulo-stamps');
write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'base_row'), row('1.2.5.0', 'base_row')));
own.repo.git('commit', '-qam', 'move the base TBD row by hand');
const base = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']);
expect(base.out, base.err).toContain('TREE: changed');
expect(base.out).toContain("id: 'base_row'");
});
test('the tree receipt refuses an undeclared TBD row restamped on the branch (GST-1026)', () => {
const undeclared = tbdStamp('restamp-tbd-undeclared-', placeholderPolicy(false), row('TBD', 'mine'));
write(undeclared.repo, NOTICE, readFile(undeclared.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine')));
undeclared.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row');
const refused = runBin(undeclared.repo, 'gstack-tree-receipt', ['--gated', undeclared.gated, '--base', 'main']);
expect(refused.out, refused.err).toContain('TREE: changed');
});
test('the tree receipt refuses a restamp of the base copy of a TBD row the branch duplicates (GST-1026)', () => {
const dup = tbdStamp('restamp-tbd-dup-', placeholderPolicy(true), row('TBD', 'base_row'));
write(dup.repo, NOTICE, notice([row('1.2.5.0', 'base_row'), '', row('TBD', 'base_row')]));
dup.repo.git('commit', '-qam', 'move the base copy of the duplicated TBD row');
const copy = runBin(dup.repo, 'gstack-tree-receipt', ['--gated', dup.gated, '--base', 'main']);
expect(copy.out, copy.err).toContain('TREE: changed');
});
/** A receipt written at the claim 1.2.4.0 after `own` changed the branch, then the stamp to 1.2.5.0, uncommitted. */
function receiptThenStamp(prefix: string, pol: Record<string, unknown>, own: (repo: FixtureRepo) => void): { repo: FixtureRepo; body: string } {
const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo);
write(repo, NOTICE, notice([row('TBD', 'base_row')]));
fs.rmSync(path.join(repo.work, 'package-lock.json'));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-carry');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
own(repo);
repo.git('add', '-A'); repo.git('commit', '-qm', 'mine');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]);
expect(w.status, w.err).toBe(EXIT.ok);
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
return { repo, body };
}
const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
// Value: protects=the receipt carry follows gbrain's restamp of the branch's own since: 'TBD' row when the policy
// declares TBD, and refuses it undeclared (GST-1026); fails_when=productSpec's own-row check ignores the policy's
// placeholders and refuses RECEIPT_CARRY_PRODUCT_CHANGED; seam=bin/gstack-ship-receipt productSpec ownRows
test.each([['declared', true], ['undeclared', false]])('the carry and a TBD row restamped on the branch: %s', (_, declared) => {
const { repo, body } = receiptThenStamp(`restamp-tbd-carry-${declared}-`, placeholderPolicy(declared), r => write(r, NOTICE, notice([row('TBD', 'base_row'), '', row('TBD', 'mine')])));
write(repo, NOTICE, readFile(repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine')));
repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row');
const carried = carry(repo, body);
if (declared) expect(carried.out, carried.err).toContain('CARRY: carried');
else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]);
});
// Value: protects=a carried receipt names the head's version, the one write would record there, after a main merge
// and a restamp (GST-1030 point 1, GGST-1); fails_when=carryReceipt copies version from the old receipt;
// seam=bin/gstack-ship-receipt cmdCarry
test('the carried receipt records the head\'s version after a main merge and a restamp (GST-1030)', () => {
const { repo, body } = receiptThenStamp('restamp-carry-version-', policy(true), r => write(r, 'src.ts', 'export const x = 0;\n'));
repo.git('reset', '-q', '--hard');
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
makePr(repo, 'pr-other');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp other');
mergeToMain(repo, 'pr-other');
repo.git('checkout', '-q', 'pr-carry');
mergeMain(repo);
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'restamp after the lost slot');
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.4.0');
const carried = carry(repo, body);
expect(carried.out, carried.err).toContain('CARRY: carried');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.5.0');
});
/** package.json with its dependency pin set to `pin`, every other field kept. */
const pinned = (repo: FixtureRepo, pin: string) => {
const pkg = JSON.parse(readFile(repo, 'package.json'));
pkg.dependencies = { ...pkg.dependencies, 'left-pad': pin };
write(repo, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
};
// Value: protects=a pin-bump PR's receipt carries across a main merge and a restamp when package.json's only change since
// review is its version field, and refuses when the pin itself moved (GST-1030, GBEV-3, gbrain-evals #306);
// fails_when=the carry counts the PR's package.json as product and refuses RECEIPT_CARRY_PRODUCT_CHANGED on a version-only
// move, or lets a changed dependency pin ride; seam=bin/gstack-ship-receipt cmdCarry walk
test.each([['the pin unchanged since review', false], ['the pin moved since review', true]])('the carry and a pin-bump PR after a main merge: %s (GST-1030)', (_, moved) => {
const { repo, body } = receiptThenStamp(`restamp-carry-pin-${moved}-`, policy(true), r => pinned(r, '1.3.0'));
repo.git('reset', '-q', '--hard');
makePr(repo, 'pr-other');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp other');
mergeToMain(repo, 'pr-other');
repo.git('checkout', '-q', 'pr-carry');
mergeMain(repo);
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
if (moved) pinned(repo, '1.3.1');
repo.git('commit', '-qam', 'restamp after the lost slot');
expect(JSON.parse(readFile(repo, 'package.json')).version).toBe('1.2.5');
const carried = carry(repo, body);
if (moved) expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]);
else expect(carried.out, carried.err).toContain('CARRY: carried');
});
// Value: protects=a version-only restamp of EVIDENCE.md is release metadata to the carry when the policy declares it in
// stamp_files, and product when it does not (GST-1030 point 2, GGST-1); fails_when=the carry counts the declared
// file's stamp as a product change (RECEIPT_CARRY_PRODUCT_CHANGED), or lets an undeclared file's version edit ride;
// seam=bin/gstack-ship-receipt productSpec, lib/ship-receipt.ts stampOnlyText
test.each([['declared in stamp_files', true], ['undeclared', false]])('the carry and a version-only restamp of EVIDENCE.md: %s (GST-1030)', (_, declared) => {
const base = policy(true);
const pol = declared ? { ...base, stamp_files: [...base.stamp_files, 'EVIDENCE.md'] } : base;
const { repo, body } = receiptThenStamp(`restamp-carry-evidence-${declared}-`, pol, r => write(r, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.4.0 with the free suite\n'));
if (!declared) write(repo, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.5.0 with the free suite\n');
expect(readFile(repo, 'EVIDENCE.md')).toBe('# Evidence\n\nVerified on v1.2.5.0 with the free suite\n');
repo.git('commit', '-qam', 'stamp 1.2.5.0');
const carried = carry(repo, body);
if (declared) expect(carried.out, carried.err).toContain('CARRY: carried');
else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]);
});
});
+1 -1
View File
@@ -149,7 +149,7 @@ describe('GST-967 invariant: no gate-class owner exit before Step 11\'s voices;
});
/**
* The GST-967 dispatch-superset no-loss check (crew rule 2026-10-30; replay 'GST-967' in test/review-packets-removed.test.ts).
* The GST-967 dispatch-superset no-loss check (crew rule 2026-10-30; replay 'GST-967' in test/fixtures/review-pins/replays/GST-967.json).
* On a 1,440-case grid (five diffs: light, standard, gate, docs, tests; passes 0-2; last none, full or fix-check; every mix of
* the four fix flags; Step 11 run or not), main's planReview at 1022f4f (v1.92.81.0) hashes to MAIN_PLANS. The branch's plan,
* with each voices step read back as main's stop (its reason is the parenthesised tail, no dispatch), hashes to the same value,
+104 -1
View File
@@ -11,7 +11,7 @@ import { EXIT } from '../lib/headless-artifacts';
import { KEEP_ARG_MAX_BYTES, readSelection, reviewExclude, tooLargeLine, unreadableLine } from '../lib/review-exclude';
import { collectCommit, collectLive } from '../lib/review-collect';
import { extractReceipt } from '../lib/ship-receipt';
import { bindShippedReceipt, receiptReviewAtHead } from '../lib/ship-receipt-depth';
import { bindShippedReceipt, excludeMismatch, receiptReviewAtHead } from '../lib/ship-receipt-depth';
import { REVIEW_EXCLUDE_KEY_VERSION, isUnavailable } from '../lib/ship-policy';
import { loadPolicyAt } from '../lib/ship-policy-at';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
@@ -313,6 +313,31 @@ describe('the receipt binds the selection the reviewers read', () => {
expect(removed.status).toBe(EXIT.fail);
expect(removed.err).toContain(`field=review_exclude expected=none recorded=${digest}`);
}, 240_000);
// Value: protects=every place that describes write --review-exclude says what it checks: the digest HEAD has under the base
// policy, not that a review read that selection; fails_when=the help, troubleshooting, ship-policy or the queue's refusal
// line claims a receipt write alone cannot clear field=review_exclude while a write with the refusal's own expected= digest
// clears it with no review run; why_new=GST-972 item 2 (the #3852 cross-family trail review); seam=gstack-ship-receipt write
// and --help, excludeMismatch, the two docs; no_claim=binding the digest to the review ledger (write never reads it)
test('write binds --review-exclude to the base policy, not to a review, and no description claims more', () => {
const repo = census();
const body = path.join(repo.root, 'body.md');
const refused = writeReceipt(repo, body, []);
const expected = refused.err.match(/field=review_exclude expected=([0-9a-f]{64}) /)?.[1];
expect(expected).toBeDefined();
expect(writeReceipt(repo, body, ['--review-exclude', expected!, ...targetedAtHead(repo)]).status).toBe(EXIT.ok);
const doc = (rel: string) => fs.readFileSync(path.join(import.meta.dir, '..', rel), 'utf8').replace(/\s+/g, ' ');
const claims = {
help: runBin(repo, 'gstack-ship-receipt', ['--help']).out.replace(/\s+/g, ' '),
troubleshooting: doc('docs/troubleshooting.md'),
shipPolicy: doc('docs/ship-policy.md'),
queue: excludeMismatch({ state: 'none' }, { review_exclude: { digest: expected } } as any, repo.git('rev-parse', 'HEAD'), 'origin/main')!,
};
for (const [where, text] of Object.entries(claims)) {
expect({ where, overClaim: /write alone (cannot|does not) clear|never a receipt write alone/i.test(text) }).toEqual({ where, overClaim: false });
expect({ where, says: text.includes('does not read the review ledger') }).toEqual({ where, says: true });
}
}, 240_000);
});
describe('a branch cannot hide code under a base pattern', () => {
@@ -348,6 +373,84 @@ describe('a branch cannot hide code under a base pattern', () => {
expect(p).toMatchObject({ depth: 'full', errors: expect.arrayContaining([expect.objectContaining({ code: 'binary_code_path', input: 'assets/loader.js' })]) });
}, 120_000);
// Value: protects=a compiled binary under a base pattern stays in review, because it is code; fails_when=a .node, .so, .so.N,
// .dylib or .dll file, or an ELF, Mach-O or PE image under a content extension (.bin, .dat), is excluded as binary content;
// why_new=GST-972 item 3; seam=bin/gstack-review-plan; no_claim=what a reader can see inside a binary
test('native binaries are code: .node, .so, .dylib, .dll and executable images under a content extension stay kept', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo);
fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true });
const elf = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), Buffer.alloc(24)]);
const macho = Buffer.concat([Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), Buffer.alloc(28)]);
const pe = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(30)]);
const files = { 'assets/addon.node': elf, 'assets/libx.dylib': macho, 'assets/libx.so': elf, 'assets/libx.so.1': elf, 'assets/setup.dat': pe, 'assets/tool.bin': elf, 'assets/x.dll': pe, 'assets/real.gz': GZ };
for (const [rel, bytes] of Object.entries(files)) fs.writeFileSync(path.join(repo.work, rel), bytes);
commit(repo, {});
const p = JSON.parse(plan(repo, ['--json']).out);
expect(p.review_exclude.kept_files).toEqual(Object.keys(files).filter(f => f !== 'assets/real.gz').map(f => ({ path: f, reason: 'code' })));
expect(p.review_exclude).toMatchObject({ excluded: 1, content: { binary: 1 } });
}, 120_000);
// Value: protects=a branch's .gitattributes cannot get an executable image excluded by making git read it as text, and a
// 64-bit universal Mach-O is an executable image; fails_when=only files git reports as binary under the branch's own
// attributes get the magic check, so an ELF named .png under '*.png diff -binary' is excluded as data, or 0xcafebabf
// is not executable magic; why_new=GST-972 item 3 follow-up (#3910, #3902 outside voice); seam=bin/gstack-review-plan and
// reviewExclude in head mode; no_claim=a NUL-free script under a content extension
test('a branch .gitattributes that makes git read an ELF as text cannot get it excluded; fat64 Mach-O is native', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo);
fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'assets/native.png'), Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), Buffer.alloc(24)]));
fs.writeFileSync(path.join(repo.work, 'assets/fat64.dat'), Buffer.concat([Buffer.from([0xca, 0xfe, 0xba, 0xbf]), Buffer.alloc(28)]));
fs.writeFileSync(path.join(repo.work, 'assets/real.gz'), GZ);
commit(repo, { '.gitattributes': '*.png diff -binary\n' });
const p = JSON.parse(plan(repo, ['--json']).out);
const kept = [{ path: 'assets/fat64.dat', reason: 'code' as const }, { path: 'assets/native.png', reason: 'code' as const }];
expect(p.review_exclude.kept_files).toEqual(kept);
expect(p.review_exclude).toMatchObject({ excluded: 1, content: { binary: 1 } });
const head = repo.git('rev-parse', 'HEAD');
const baseSha = repo.git('rev-parse', 'origin/main');
const c = collectCommit(repo.work, baseSha, head);
const x = reviewExclude({ root: repo.work, baseSha, ref: 'origin/main', mergeBase: c.mergeBase!, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged: new Set(), context: 'test' });
expect(x.state === 'active' && x.kept).toEqual(kept);
expect(x.state === 'active' && x.record.digest).toBe(p.review_exclude.digest);
}, 120_000);
// Value: protects=a NUL byte cannot turn code or a key file into excludable content; fails_when=a NUL-bearing file with a
// source extension outside the old CODE_EXT list (.gradle, .sql, .tf, .groovy), a key store (.p12) or an extensionless key
// is excluded as binary; why_new=GST-972 item 6 (#3852 targeted gate reader at 61803d9); seam=bin/gstack-review-plan;
// no_claim=the list of binary content extensions is complete (an unknown one is kept, never excluded)
test('a NUL-bearing file is binary content only under a known content extension; code and key files stay kept', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo);
fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true });
const nul = (code: string) => Buffer.from(`/* \0 */ ${code}\n`);
const kept = { 'assets/Build.groovy': nul('println "x"'), 'assets/build.gradle': nul('apply plugin: "java"'), 'assets/id_ed25519': nul('openssh-key-v1'), 'assets/main.tf': nul('resource "x" "y" {}'), 'assets/query.sql': nul('DROP TABLE users;'), 'assets/server.p12': Buffer.from([0x30, 0x82, 0, 1, 2, 3]) };
const content = { 'assets/report.pdf': GZ, 'assets/shot.png': GZ };
for (const [rel, bytes] of Object.entries({ ...kept, ...content })) fs.writeFileSync(path.join(repo.work, rel), bytes);
commit(repo, {});
const p = JSON.parse(plan(repo, ['--json']).out);
expect(p.review_exclude.kept_files).toEqual(Object.keys(kept).map(f => ({ path: f, reason: 'code' })));
expect(p.review_exclude).toMatchObject({ excluded: 2, content: { binary: 2 } });
}, 120_000);
// Value: protects=a branch's .gitattributes cannot get a matched file with a credential line excluded by making git call it
// binary, at the plan (live tree) and at the receipt (head blobs); fails_when=the secret-line rule reads only the
// collector's added lines, which git leaves empty for an attribute-binary file, so the file is excluded; why_new=GST-972
// item 5 (#3852 round 3, outside-voice#2); seam=bin/gstack-review-plan and reviewExclude in head mode; no_claim=pregate's gitleaks scan, what readers' own diffs show (they still follow the attributes)
test('a branch .gitattributes marking receipts binary cannot get a credential-line file excluded: the keep rule reads the text', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['receipts/**']) } }); repos.push(repo);
const secret = '{"cmd": "curl -H $OPENAI_API_KEY"}\n';
commit(repo, { '.gitattributes': 'receipts/** binary\n', 'receipts/s.json': secret, 'receipts/a.b/c.json': secret, 'receipts/a.json': '{"ok": true}\n', 'receipts/a/t.json': '{"ok": true}\n' });
const p = JSON.parse(plan(repo, ['--json']).out);
const kept = [{ path: 'receipts/a.b/c.json', reason: 'secret-line' as const }, { path: 'receipts/s.json', reason: 'secret-line' as const }];
expect(p.review_exclude.kept_files).toEqual(kept);
expect(p.review_exclude).toMatchObject({ excluded: 2, content: { data: 2 } });
const head = repo.git('rev-parse', 'HEAD');
const baseSha = repo.git('rev-parse', 'origin/main');
const c = collectCommit(repo.work, baseSha, head);
const x = reviewExclude({ root: repo.work, baseSha, ref: 'origin/main', mergeBase: c.mergeBase!, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged: new Set(), context: 'test' });
expect(x.state === 'active' && x.kept).toEqual(kept);
expect(x.state === 'active' && x.record.digest).toBe(p.review_exclude.digest);
}, 120_000);
test('an unavailable gate scan keeps every candidate instead of excluding it', () => {
const repo = census();
const head = repo.git('rev-parse', 'HEAD');
+17
View File
@@ -191,6 +191,23 @@ describe('lib: deriveFromFindings, bindReview order and reviewFreshness', () =>
expect(reviewFreshness(bound, 'd'.repeat(64))?.status).toBe('STALE');
});
// Value: protects=a fix-check row is written with the fields review-read verifies (GEVA-218: the row lacked them, so it read
// UNVERIFIED and finish could not count it); fails_when=the logger accepts a review-fix-check row without completed and
// converged, or consumes the start token while refusing it; seam=bindReview
test('a review-fix-check row without completed and converged is refused and keeps its start token (GST-1130)', () => {
const dir = path.join(home, 'starts', '.review-starts');
fs.mkdirSync(dir, { recursive: true });
const token = '77777777-2222-3333-4444-555555555555';
const w = 'e'.repeat(64);
fs.writeFileSync(path.join(dir, `${token}.json`), JSON.stringify({ skill: 'review-fix-check', repo: '/r', branch: 'b', wtree: w, started_at: 't' }));
const env = { GSTACK_REVIEW_DIR: path.join(home, 'starts'), GSTACK_REVIEW_REPO: '/r', GSTACK_REVIEW_BRANCH: 'b', GSTACK_STAMP_WTREE: w };
for (const row of [{}, { completed: true }, { converged: true }, { completed: 'yes', converged: true }]) {
expect(() => bindReview({ skill: 'review-fix-check', status: 'clean', ...row }, token, env)).toThrow('review-log: a review-fix-check row must carry completed and converged as true or false');
}
expect(fs.existsSync(path.join(dir, `${token}.json`))).toBe(true);
expect(bindReview({ skill: 'review-fix-check', status: 'clean', completed: true, converged: true }, token, env).review_binding.state).toBe('verified');
});
test('reviewFreshness reads findings_open: resolved criticals are CURRENT, claimed issues_found stays conservative', () => {
const w = 'b'.repeat(64);
const base = { skill: 'review', completed: true, converged: true, wtree: w, review_binding: { state: 'verified', start_wtree: w, end_wtree: w } };
+160 -71
View File
@@ -11,6 +11,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { sectionTexts } from '../lib/review-brief';
import { mergePinEdits, type PinMerge } from './helpers/pin-merge-fixture';
const ROOT = path.join(import.meta.dir, '..');
const REMOVED = [
@@ -49,69 +50,88 @@ describe('reviewer packets stay reverted (GSTA-167)', () => {
* reviewer sees or how its findings rate, so it merges only after its preregistered no-loss replay
* passes (GStack Minder crew rule, 2026-10-30); a cost replay alone is not enough (#3621 passed its
* cost replay, then its no-loss replay found 11 of 30 blockers). Every pin names the replay that
* admits its bytes: repin with that replay's id here and in the commit message, and merge only on
* admits its bytes: repin with that replay's id in the pin file and in the commit message, and merge only on
* its passing result.
*
* The pins live one file per pin under test/fixtures/review-pins (GST-1084), so two PRs that repin
* different files, or add different replays, never edit the same lines and merge without a conflict:
* files/<pinned path>.json {"sha", "replay"}: the sha256 of the file's LF-normalized bytes and the replay that admits them
* replays/<slug>.json {"replay", "justification"}: one replay id and its written no-loss justification,
* where <slug> is replaySlug(replay)
* brief-bodies/<name>.json {"sha"}: one brief body lib/review-brief.ts cuts from the ship sections (below)
*/
const NO_LOSS_REPLAYS = {
'#3638': 'no-loss round 3: main restored the pre-#3621 reviewers (v1.91.331.0)',
'PR-a': "GSTA-167 variant G Arm 4: structural plan identity at the 14 cohort heads and the free exact re-score of saved C, D and A' findings (parts 4a and 4b)",
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
'GST-508': "GST-508 (#3744, GSTA-202) preregistered no-loss replay of review_exclude_paths at ef2760f: Arm V found 33 of 41 blockers vs R 33 of 41, exact sign flip with delta 4, p_NI 0.0279 (pass); Arm P identical depth, dispatch and reader sets on 26 of 26 units (GStack Minder drive handoffs/GSTA-202/noloss-results.md)",
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged",
'GST-464': "GST-464 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md is byte-identical to the bodies at the L and GST-257 pins; only lead instructions outside the cut spans changed",
'GST-850': "GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (wider security and money classes, write-up text in the batched diff) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-risk-linear.test.ts); reviewer text is unchanged",
'GSTA-224 H1': 'GSTA-224 H1 preregistered no-loss run: the PR-type checklist against main\'s checklist and a no-gstack arm (Opus 5.5, 81 calls), 5 behavior cases, 10 Round 7 application blockers and 2 clean controls; every bar passed (handoffs/GSTA-224/h1-noloss-results.md)',
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
'GST-508+R51': "wave review-53cfb5bfe011 merge of #3744 into main: lib/review-depth.ts keeps both admitted changes, the GST-508 no-loss replay of review_exclude_paths (Arm V 33 of 41 blockers vs R 33 of 41, p_NI 0.0279; GStack Minder drive handoffs/GSTA-202/noloss-results.md) and the R51 risk-tier re-score with GST-850's dispatch-superset no-loss check: excluded paths sit beside the added and removed line maps, and the exclusion filter keeps the publishes-docs rule",
'GST-508+GST-464': "wave review-53cfb5bfe011 merge of #3744 into main: ship/sections/review-army.md and adversarial.md carry the GST-508 no-loss-replayed exclude pathspec placeholders inside the brief bodies (handoffs/GSTA-202/noloss-results.md) and the GST-464 lead-instruction edits outside the cut spans, which its brief-identity no-loss check admitted",
'GST-986': "GST-986 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/adversarial.md is byte-identical to the bodies at the GST-257 pin; only the lead's Step 11 instruction naming the targeted record's path changed, outside the cut spans",
'GST-911-redos': "GST-911 ReDoS fix verdict-identity no-loss check (wave review-e7b551ebd865): SVG_REFUSED's url() entry, rewritten to run in linear time, refuses exactly the strings #3838's pattern refused (test/review-risk-redos.test.ts: 60,000 seeded strings and the hand-picked edges, and every SVG blob in the repository's history, with zero differences); SVG_MARKUP now refuses a DOCTYPE naming any document but W3C's SVG DTD (test/review-planner-misreads.test.ts), which only keeps more SVGs code and so drops no reviewer; GST-911's no-loss replay holds unchanged; reviewer text is unchanged",
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged",
'GST-508+R51+GST-911': "wave review-53cfb5bfe011 merge of main 3d99c2249 (#3848) into #3852: lib/review-depth.ts keeps GST-508's review_exclude_paths (no-loss replay, GStack Minder drive handoffs/GSTA-202/noloss-results.md), R51 with GST-850's dispatch-superset no-loss check, and GST-911's SVG drawings with GST-911-redos (no-loss replay: 0 of 100 main commits changed, reports/GSTA-249-911-decisions.tsv; test/review-planner-misreads.test.ts, test/review-risk-redos.test.ts); lib/review-risk.ts is main's GST-911 bytes. Merged-head checks (handoffs/GSTA-202/merge-3d99c22-checks.md): Arm P on unit 3593 differs only in the exclude pathspec and the disclosure line, the same brief diff as before the merge, and GST-911 panel unit f5cb6ec1e plans, dispatches and renders byte-identical briefs under 3d99c2249 and the merged head",
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
'GST-967': "GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged",
'GST-508+GST-464+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: ship/sections/review-army.md and adversarial.md are main's GST-967 bytes plus exactly the GST-508+GST-464 lines (the merged file minus main equals the wave minus fc292e22f, line for line), so GST-967's dispatch-superset no-loss check and GST-508's no-loss replay both hold; merged-head checks in GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md",
'GST-508+R51+GST-911+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: lib/review-depth.ts is main's GST-967 bytes plus exactly the GST-508+R51+GST-911 lines (the merged file minus main equals the wave minus fc292e22f, line for line); GST-967's dispatch-superset no-loss check (test/review-depth.test.ts) passes on the merged file, and the GST-508+R51+GST-911 checks are rerun at the merged head (GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md)",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
'ship/sections/review-army.md': { sha: 'de3e0d35f2af56492c42fd287b6868fbe19bce96b86321e74a4947ba909b74eb', replay: 'GST-508+GST-464+GST-967' },
'ship/sections/adversarial.md': { sha: '0142d6bc3f2a5bceb9f8b2adef1d3a206e5836c99a0667fe995568da2e80a5a6', replay: 'GST-508+GST-464+GST-967' },
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
'review/checklist.md': { sha: 'a32963ef38d9d3613c58c805e8517cd2e593c63769b30dd7d40e2592fa48fc78', replay: 'GSTA-224 H1' },
'review/design-checklist.md': { sha: 'b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83', replay: '#3638' },
'review/gate-integrity.md': { sha: 'f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332', replay: '#3638' },
'review/specialists/api-contract.md': { sha: '263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95', replay: '#3638' },
'review/specialists/data-migration.md': { sha: 'b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca', replay: '#3638' },
'review/specialists/maintainability.md': { sha: 'f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b', replay: '#3638' },
'review/specialists/performance.md': { sha: '545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc', replay: '#3638' },
'review/specialists/red-team.md': { sha: '9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427', replay: '#3638' },
'review/specialists/security.md': { sha: '17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f', replay: '#3638' },
'review/specialists/simplification.md': { sha: 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f', replay: '#3638' },
'review/specialists/state-machines.md': { sha: '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2', replay: '#3638' },
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
'lib/review-depth.ts': { sha: 'd76a9afcfadfe0a95b15428c04e6f7a88c6b0fa75591201c45f023fec2bb90db', replay: 'GST-508+R51+GST-911+GST-967' },
'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-508+R51+GST-911' },
};
const PINS = path.join(ROOT, 'test/fixtures/review-pins');
const replaySlug = (replay: string) => replay.replace(/[^A-Za-z0-9+-]+/g, '-').replace(/^-+|-+$/g, '');
/**
* The reviewer brief bodies lib/review-brief.ts cuts out of the ship sections, at the bytes replays L and GST-257
* admitted, except fixtures, redTeam and native, which carry the exclude pathspec placeholders the GST-508 replay
* admitted (byte-identical to #3744 at 995e442). A section repin under GST-464 or GST-986 is valid only while every
* body still hashes here: the section may change outside the cut spans, never inside them.
*/
const BRIEF_BODIES: Record<string, string> = {
specialist: '1ebe47ddb3291a9e4fe90b5a260eb57e328144959549bc95c119b68d23652cb5',
framing: 'aad667b5ea4c6d23d930dafffa38178c04be388cd4364fb73759fc92af17bb63',
fixtures: 'fbe52f3a9e0f4442eb755f1747a95944b373b439bd05c61f3eb68345d1061a5b',
redTeam: '6dd670ea040535ca6cceab81c29a395bb67ce5a2ab8fa2b81e51bc4711e4aada',
native: 'a7cc08fb31b6ca65932a316d589f0b09677426d193d63f88ec74fee6d93ce053',
outside: 'bd96406da28b03742384560425a333b6d6d7ed2be95bd2026c90e0039632cee2',
};
/** Every pin file under dir, keyed by its path below dir without `.json`; any other file is an error. */
function readPinDir(dir: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const rel of (fs.readdirSync(dir, { recursive: true }) as string[]).map(f => f.split(path.sep).join('/')).sort()) {
if (fs.statSync(path.join(dir, rel)).isDirectory()) continue;
if (!rel.endsWith('.json')) throw new Error(`${dir}/${rel}: every pin file is <name>.json`);
try {
out[rel.slice(0, -'.json'.length)] = JSON.parse(fs.readFileSync(path.join(dir, rel), 'utf8'));
} catch (e) {
throw new Error(`${dir}/${rel}: ${(e as Error).message}`);
}
}
return out;
}
/** The pin store at pins, with every file that is not exactly its kind's fields as non-empty strings, or is misnamed. */
function readPins(pins: string) {
const malformed: string[] = [];
const kind = <K extends string>(sub: string, keys: readonly K[]) => {
const out: Record<string, Record<K, string>> = {};
for (const [name, value] of Object.entries(readPinDir(path.join(pins, sub)))) {
const fields = typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
if (Object.keys(fields).sort().join() === [...keys].sort().join() && keys.every(k => typeof fields[k] === 'string' && fields[k] !== '')) out[name] = fields as Record<K, string>;
else malformed.push(`${sub}/${name}.json (expected exactly ${keys.join(', ')})`);
}
return out;
};
const replayFiles = kind('replays', ['replay', 'justification']);
for (const [name, r] of Object.entries(replayFiles)) if (name !== replaySlug(r.replay)) malformed.push(`replays/${name}.json (replay ${r.replay} belongs in replays/${replaySlug(r.replay)}.json)`);
return {
malformed,
replays: Object.fromEntries(Object.values(replayFiles).map(r => [r.replay, r.justification])),
files: kind('files', ['sha', 'replay']),
bodies: Object.fromEntries(Object.entries(kind('brief-bodies', ['sha'])).map(([name, body]) => [name, body.sha])),
};
}
type Pins = ReturnType<typeof readPins>;
/** Every source a reviewer brief, checklist or depth rule comes from: deleting one of these pins fails, never drops the check. */
const MUST_PIN = [
'lib/review-depth.ts',
'lib/review-risk.ts',
'review/checklist.md',
'review/design-checklist.md',
'review/gate-integrity.md',
'review/sections/adversarial.md',
'review/sections/review-army.md',
'scripts/resolvers/fix-check.ts',
'ship/sections/adversarial.md',
'ship/sections/review-army.md',
];
/** MUST_PIN entries and specialist checklists under root with no pin. */
const unpinned = (pins: Pins, root: string) => [...MUST_PIN, ...fs.readdirSync(path.join(root, 'review/specialists')).filter(f => f.endsWith('.md')).map(f => `review/specialists/${f}`)]
.filter(f => !Object.hasOwn(pins.files, f));
/** Pinned files whose LF-normalized bytes under root no longer hash to their pin. */
const stalePins = (pins: Pins, root: string) => Object.entries(pins.files).filter(([file, pin]) => {
const text = fs.readFileSync(path.join(root, file), 'utf8').replace(/\r\n/g, '\n');
return createHash('sha256').update(text).digest('hex') !== pin.sha;
}).map(([file, pin]) => `${file} (a repin needs a passing no-loss replay; the current pin names ${pin.replay})`);
/** Pins naming no replay in the store, and replays whose justification names no no-loss replay or re-score. */
const unadmitted = (pins: Pins) => [
...Object.entries(pins.files).filter(([, pin]) => !Object.hasOwn(pins.replays, pin.replay)).map(([file, pin]) => `${file} names unknown replay ${pin.replay}`),
...Object.entries(pins.replays).filter(([, what]) => !/no-loss|re-score/.test(what)).map(([replay]) => `replay ${replay} is not a no-loss replay or re-score`),
];
const PINNED = readPins(PINS);
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
// Value: protects=the GST-986 repin of ship/sections/adversarial.md admits only a lead-instruction edit: every brief a reviewer
@@ -119,34 +139,103 @@ describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () =>
// section pin moves; seam=sectionTexts, the function every render uses; no_claim=the checklists (pinned by file hash below)
test('every reviewer brief cut from the ship sections is byte-identical to its pinned body', () => {
const hashes = (root: string) => Object.fromEntries(Object.entries(sectionTexts(root)).map(([k, v]) => [k, createHash('sha256').update(v).digest('hex')]));
expect(hashes(ROOT)).toEqual(BRIEF_BODIES);
expect(hashes(ROOT)).toEqual(PINNED.bodies);
const copy = fs.mkdtempSync(path.join(os.tmpdir(), 'brief-bodies-'));
try {
fs.cpSync(path.join(ROOT, 'ship', 'sections'), path.join(copy, 'ship', 'sections'), { recursive: true });
const adversarial = path.join(copy, 'ship', 'sections', 'adversarial.md');
fs.writeFileSync(adversarial, fs.readFileSync(adversarial, 'utf8').replace('Recommendation:', 'Recommendation (short):'));
expect(hashes(copy)).not.toEqual(BRIEF_BODIES);
expect(hashes(copy)).not.toEqual(PINNED.bodies);
} finally {
fs.rmSync(copy, { recursive: true, force: true });
}
});
test('every pinned reviewer brief, checklist and depth rule hashes to its pin', () => {
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, pin]) => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8').replace(/\r\n/g, '\n');
return createHash('sha256').update(text).digest('hex') !== pin.sha;
}).map(([file]) => `${file} (a repin needs a passing no-loss replay; the current pin names ${REVIEWER_BRIEFS[file]!.replay})`);
expect(drift).toEqual([]);
expect(PINNED.malformed).toEqual([]);
expect(stalePins(PINNED, ROOT)).toEqual([]);
});
test('every pin names the preregistered no-loss replay that admits its bytes', () => {
const unnamed = Object.entries(REVIEWER_BRIEFS).filter(([, pin]) => !(pin.replay in NO_LOSS_REPLAYS)).map(([file]) => file);
expect(unnamed).toEqual([]);
for (const what of Object.values(NO_LOSS_REPLAYS)) expect(what).toMatch(/no-loss|re-score/);
expect(unadmitted(PINNED)).toEqual([]);
});
test('the pins cover every specialist checklist', () => {
const specialists = fs.readdirSync(path.join(ROOT, 'review/specialists')).filter(f => f.endsWith('.md')).map(f => `review/specialists/${f}`);
expect(specialists.filter(f => !(f in REVIEWER_BRIEFS))).toEqual([]);
expect(specialists.filter(f => !Object.hasOwn(PINNED.files, f))).toEqual([]);
});
test('every source a reviewer brief, checklist or depth rule comes from has a pin (GST-1084)', () => {
expect(unpinned(PINNED, ROOT)).toEqual([]);
});
// Value: protects=the one-file-per-pin layout keeps every check the in-file table had; fails_when=a stale sha, an unknown or
// unjustified replay, a malformed or misnamed pin file, or a stray file passes; seam=readPins, stalePins and unadmitted, the
// functions the checks above call; no_claim=that a repin's replay actually passed (the commit and PR carry that)
test('a stale pin, an unknown or unjustified replay and a malformed pin file each fail (GST-1084)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'review-pins-'));
try {
const src = path.join(tmp, 'src'), pins = path.join(tmp, 'pins');
for (const file of Object.keys(PINNED.files)) fs.cpSync(path.join(ROOT, file), path.join(src, file));
fs.cpSync(PINS, pins, { recursive: true });
expect(stalePins(readPins(pins), src)).toEqual([]);
fs.writeFileSync(path.join(src, 'review/checklist.md'), fs.readFileSync(path.join(src, 'review/checklist.md'), 'utf8').replace(/\n/g, '\r\n'));
expect(stalePins(readPins(pins), src)).toEqual([]);
fs.appendFileSync(path.join(src, 'review/checklist.md'), 'One more rule.\r\n');
expect(stalePins(readPins(pins), src)).toEqual([`review/checklist.md (a repin needs a passing no-loss replay; the current pin names ${PINNED.files['review/checklist.md']!.replay})`]);
const pin = (file: string, value: unknown) => fs.writeFileSync(path.join(pins, 'files', `${file}.json`), `${JSON.stringify(value)}\n`);
pin('review/design-checklist.md', { sha: PINNED.files['review/design-checklist.md']!.sha, replay: 'GST-0000' });
pin('lib/review-risk.ts', { sha: PINNED.files['lib/review-risk.ts']!.sha, replay: 'toString' });
fs.writeFileSync(path.join(pins, 'replays', 'GST-0001.json'), `${JSON.stringify({ replay: 'GST-0001', justification: 'reviewed by hand' })}\n`);
expect(unadmitted(readPins(pins))).toEqual(['lib/review-risk.ts names unknown replay toString', 'review/design-checklist.md names unknown replay GST-0000',
'replay GST-0001 is not a no-loss replay or re-score']);
expect(unpinned(readPins(pins), ROOT)).toEqual([]);
fs.rmSync(path.join(pins, 'files', 'lib/review-risk.ts.json'));
fs.rmSync(path.join(pins, 'files', 'review/specialists/testing.md.json'));
expect(unpinned(readPins(pins), ROOT)).toEqual(['lib/review-risk.ts', 'review/specialists/testing.md']);
pin('review/gate-integrity.md', { ...PINNED.files['review/gate-integrity.md'], note: 'extra' });
fs.writeFileSync(path.join(pins, 'replays', 'GST-0002.json'), `${JSON.stringify({ replay: 'GST-0003', justification: 'a no-loss replay' })}\n`);
expect(readPins(pins).malformed).toEqual(['replays/GST-0002.json (replay GST-0003 belongs in replays/GST-0003.json)',
'files/review/gate-integrity.md.json (expected exactly sha, replay)']);
fs.writeFileSync(path.join(pins, 'files', 'review/gate-integrity.md.json'), '<<<<<<< a\n{}\n');
expect(() => readPins(pins)).toThrow('files/review/gate-integrity.md.json: ');
fs.rmSync(path.join(pins, 'files', 'review/gate-integrity.md.json'));
fs.writeFileSync(path.join(pins, 'brief-bodies', 'specialist.txt'), 'stray\n');
expect(() => readPins(pins)).toThrow('every pin file is <name>.json');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
// Value: protects=PRs that repin different review files merge without a conflict (five main-merge conflicts on 2026-11-02 came
// from the in-file table); fails_when=two repins of adjacent pins, each with its own new replay, conflict in a real merge, or
// two repins of one pin merge silently; seam=git merge-tree --write-tree on two branches; no_claim=semantic merge correctness
test('repins of two different files, each with its own new replay, merge cleanly in a real git merge (GST-1084)', () => {
const repin = (file: string, sha: string, replay: string) => (root: string) => {
fs.writeFileSync(path.join(root, 'files', `${file}.json`), `${JSON.stringify({ sha, replay })}\n`);
fs.writeFileSync(path.join(root, 'replays', `${replaySlug(replay)}.json`), `${JSON.stringify({ replay, justification: `${replay} no-loss replay` })}\n`);
};
const seed = (root: string) => fs.cpSync(PINS, root, { recursive: true });
const army = 'ship/sections/review-army.md', adversarial = 'ship/sections/adversarial.md';
const merges: PinMerge[] = [];
try {
const clean = mergePinEdits(seed, repin(army, 'a'.repeat(64), 'GST-9001+GST-967'), repin(adversarial, 'b'.repeat(64), 'GST-9002'));
merges.push(clean);
const conflict = mergePinEdits(seed, repin(army, 'a'.repeat(64), 'GST-9001'), repin(army, 'c'.repeat(64), 'GST-9003'));
merges.push(conflict);
expect(clean.output).not.toContain('CONFLICT');
expect(clean.clean).toBe(true);
expect(JSON.parse(clean.read(`files/${army}.json`))).toEqual({ sha: 'a'.repeat(64), replay: 'GST-9001+GST-967' });
expect(JSON.parse(clean.read(`files/${adversarial}.json`))).toEqual({ sha: 'b'.repeat(64), replay: 'GST-9002' });
expect(JSON.parse(clean.read('replays/GST-9001+GST-967.json')).replay).toBe('GST-9001+GST-967');
expect(JSON.parse(clean.read('replays/GST-9002.json')).replay).toBe('GST-9002');
expect(conflict.clean).toBe(false);
expect(conflict.output).toContain(`CONFLICT (content): Merge conflict in files/${army}.json`);
} finally {
for (const merge of merges) fs.rmSync(merge.root, { recursive: true, force: true });
}
});
});
+21 -1
View File
@@ -125,7 +125,9 @@ describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is se
// Value: protects=only an SVG made of drawing elements is data: anything that loads, links or declares outside that set stays code;
// fails_when=an external image or stylesheet, an entity declaration, an external DTD, an animation or a CSS url() to another document reads as docs,
// including through a CSS escape or a zero-padded character reference (review pass 1 findings) or behind a stray quote that
// pairs with a later one (review pass 2 finding), or any CSS beyond declarations and url(#id) (Step 11 finding: image-set());
// pairs with a later one (review pass 2 finding), or any CSS beyond declarations and url(#id) (Step 11 finding: image-set()),
// including CSS behind a character-reference </style> or comment, an HTML-only comment end, a quoted > on the style tag, a
// non-ASCII space before > in </style>, or a tag or attribute under a non-ASCII namespace prefix (GST-940);
// seam=collectCommit + depthOfCommitInput
test.each([
['an external image', ' <image href="https://x.test/pixel.png" width="1" height="1"/>'],
@@ -153,11 +155,29 @@ describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is se
['a style attribute cross-fade()', ' <rect style="fill:cross-fade(url(https://x.test/a.png), url(#m0), 50%)"/>'],
['a style element whose </style> hides in a comment', ' <style>svg{fill:<!-- </style> -->image-set("https://x.test/p.png" 1x)}</style>'],
['a style element whose </style> hides in CDATA', ' <style><![CDATA[ /* </style> */ svg{fill:image-set("https://x.test/p.png" 1x)} ]]></style>'],
['a character-reference </style> in a style element', ' <style>&#60;/style>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a hex character-reference </style> in a style element', ' <style>&#x3C;/style>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a character-reference comment in a style element', ' <style>&#60;!-- rect{fill:image-set("https://x.test/p.png" 1x)} --></style>'],
['CSS after an HTML empty comment in a style element', ' <style><!-->rect{fill:image-set("https://x.test/p.png" 1x)}--></style>'],
['CSS after an HTML --!> comment end in a style element', ' <style><!-- --!>rect{fill:image-set("https://x.test/p.png" 1x)} --></style>'],
['a </style> inside a quoted style attribute', ' <style title="></style>">rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['an escaped ampersand in a style element', ' <style>text{font-family:"A &amp; B"}</style>'],
['a no-break space before > in </style>', ' <style>a{}</style\u00a0>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a vertical tab before > in </style>', ' <style>a{}</style\u000b>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a script under a non-ASCII prefix', ' <g\u00e9:script xmlns:g\u00e9="http://www.w3.org/2000/svg">fetch(1)</g\u00e9:script>'],
['a style under a non-ASCII prefix', ' <g\u00e9:style xmlns:g\u00e9="http://www.w3.org/2000/svg">rect{fill:image-set("https://x.test/p.png" 1x)}</g\u00e9:style>'],
['an external href under a non-ASCII prefix', ' <image x\u00e9:href="https://x.test/p.png" xmlns:x\u00e9="http://www.w3.org/1999/xlink"/>'],
])('%s keeps the SVG code', (_name, line) => {
const fx = fixture();
expect(plan(fx, fx.commit({ 'results/chart.svg': chart(5, line) })).class).toBe('code');
}, 60_000);
// Value: protects=the GST-940 style walker still reads a plain chart style as a drawing: a comment, CDATA and a quoted attribute
// without markup; fails_when=the walker over-refuses them, so chart SVGs lose the light review; seam=svgDrawing
test('a style comment, CDATA and quoted attribute keep a chart a drawing (GST-940)', () => {
expect(svgDrawing(chart(5, ' <style type="text/css" media="all"><!-- plot --><![CDATA[ rect{fill:#000} ]]></style>'))).toBe(true);
});
// Value: protects=a passive edit to an SVG that already carries a script is still code, because the whole file decides, not the
// changed lines; and an SVG code names stays code like any consumed data file; fails_when=either plans as docs; seam=collectCommit
test('an edit inside a scripted SVG, and an SVG code names, stay code', () => {
+2 -1
View File
@@ -7,6 +7,7 @@ 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 { bunIsolationArgs } from '../lib/bun-isolation';
import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECURITY_LINE, matchRisk } from '../lib/review-risk';
const ROOT = path.join(import.meta.dir, '..');
@@ -51,7 +52,7 @@ describe('the rules hashes cover every new pattern', () => {
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
const hash = () => {
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
return r.stdout.trim();
};
+24 -5
View File
@@ -5,7 +5,7 @@
*/
import { describe, expect, test } from 'bun:test';
import { SVG_REFUSED, svgDrawing } from '../lib/review-depth';
import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECRET_PATH, SECURITY_LINE, matchRisk } from '../lib/review-risk';
import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECRET_PATH, SECURITY_JOINED, SECURITY_LINE, matchRisk } from '../lib/review-risk';
const L = 100_000;
const ms = (f: () => unknown) => { const t = performance.now(); f(); return performance.now() - t; };
@@ -18,7 +18,7 @@ describe('linear time on hostile input', () => {
// or on a line of repeated anchors; seam=every pattern in SECURITY_LINE, DATA_LINE, SECRET_LINE, MONEY_LINE and SECRET_PATH
const hostile = [
run('rm -', 'r'), run('rm -', 'f'), run('rm -r', 'r'), run('rm -f', 'a'), run('rm\t-', 'r'), run('rm ', ' '), run('rm -', 'r', '1'), run('rm -', 'rf', '_'), run('', 'rm -r '),
run('', 'rmSync('), run('rmSync(', ' '), run('rmSync(', 'recursive '), run('rmSync(', 'recursive: '),
run('', 'rmSync('), run('rmSync(', ' '), run('rmSync(', 'recursive '), run('rmSync(', 'recursive: '), run('', '.rm('), run('.rm(', ' '), run('.rm(', 'recursive: '), run('', 'rm('), run('rm(', ' '), run('rmSync(', ' \n'),
run('shell', ' '), run('shell:', ' '), run('shell=', ' '), run('', '; '), run(';', ' '), run('; eval', ' '), run('eval', ' '), run('', ';eval '),
run('execSync', ' '), run('child_process.exec', ' '), run('os.system', ' '), run('new', ' '), run('new Function', ' '),
run('spawn(', ' '), run('Bun.spawn(', ' '), run('Bun.spawn([', ' '), run('innerHTML', ' '),
@@ -27,7 +27,7 @@ describe('linear time on hostile input', () => {
run('', 'a_'), run('', 'aU'), run('a', 'A'), run('spend', '_'), run('new Anthropic', ' '), run('.messages.create', ' '),
run('$', 'A_'), run('${', 'A_'), run('', '$A_'), run('process.env.', 'A_'), run('process.env[', ' '), run('os.environ[', ' '), run('os.getenv(', ' '), run('ENV[', ' '),
];
test.each(Object.entries({ SECURITY_LINE, DATA_LINE, SECRET_LINE, MONEY_LINE, SECRET_PATH: [SECRET_PATH] }))('every %s pattern finishes under 100 ms on each 100k-character hostile line', (_n, patterns) => {
test.each(Object.entries({ SECURITY_LINE, SECURITY_JOINED, DATA_LINE, SECRET_LINE, MONEY_LINE, SECRET_PATH: [SECRET_PATH] }))('every %s pattern finishes under 100 ms on each 100k-character hostile line', (_n, patterns) => {
const slow = patterns.flatMap(r => hostile.map(([label, s]) => [r.source.slice(0, 50), label, Math.round(ms(() => r.test(s)))] as const)).filter(([, , t]) => t >= 100);
expect(slow).toEqual([]);
}, 120_000);
@@ -57,13 +57,13 @@ describe('the rm rule matches what R51 matched', () => {
expect(lines.filter(s => r51.test(s)).length).toBeGreaterThan(1000);
expect(edges.filter(s => rm.test(s))).toEqual(['rm -rf /', 'rm -fr x', 'rm -rvf x', 'rm -rf-x', 'rm -rrf', 'rm -ffr', 'rm -rfr', 'rm\t\t-rf', 'sudo rm -rf "$DIR"']);
});
test('the rmSync rule matches what R51 matched on 60,000 seeded short lines, and padding past its window still raises security', () => {
test('the rmSync rule matches every line R51 matched on 60,000 seeded short lines, and padding past its window still raises security', () => {
x = 898;
const r51Sync = /\brmSync\s*\([^)]*recursive\s*:\s*true/;
const rmSync = SECURITY_LINE.find(r => r.source.startsWith(String.raw`\brmSync`))!;
const parts = ['rmSync(', 'rmSync (', 'xrmSync(', 'recursive', ':', ' ', 'true', ')', 'a', 'recursive: true', 'rmSync', '(', '\t'];
const lines = Array.from({ length: 60_000 }, () => Array.from({ length: next(9) + 1 }, () => parts[next(parts.length)]).join(''));
expect(lines.filter(s => rmSync.test(s) !== r51Sync.test(s))).toEqual([]);
expect(lines.filter(s => r51Sync.test(s) && !rmSync.test(s))).toEqual([]);
expect(lines.filter(s => r51Sync.test(s)).length).toBeGreaterThan(1000);
expect(security('fs.rmSync(dir, { recursive: true, force: true });')).toBe(true);
expect(security('fs.rmSync(' + 'a'.repeat(480) + ', { recursive: true })')).toBe(true);
@@ -71,6 +71,25 @@ describe('the rm rule matches what R51 matched', () => {
expect(security('fs.rmSync(dir, /*' + 'x'.repeat(5_000_000) + '*/ { recursive: true });')).toBe(true);
expect(security('fs.rmSync(dir, { force: true });')).toBe(false);
});
test('a recursive rm or rmSync whose first argument is a call, or whose arguments hold a ), raises security (GST-942)', () => {
// Value: protects=a recursive delete written the common way keeps the security class; fails_when=the rule stops at the first
// ) inside the first argument or a comment, or skips the async fs.rm; seam=matchRisk on one added line
const recursive = ['fs.rmSync(path.join(dir, "x"), { recursive: true, force: true });', 'fs.rmSync(dir, /* ) */ { recursive: true });',
'fs.rmSync(path.resolve(root, name), { recursive: true });', 'await fs.promises.rm(path.join(dir, "x"), { recursive: true });',
'await fs.rm(dir, { recursive: true })', "await rm(dir, { recursive: true, force: true }); // import { rm } from 'node:fs/promises'",
'fs.rm(' + 'a'.repeat(600) + ', { force: true })'];
expect(recursive.filter(l => !security(l))).toEqual([]);
expect(['fs.rm(path.join(dir, "x"), { force: true });', 'farm(x, { recursive: true });', 'confirm(x, { recursive: true });'].filter(security)).toEqual([]);
});
test('a recursive delete a formatter split across lines raises security, read over the joined lines (GST-942 review)', () => {
// Value: protects=a Prettier-formatted rmSync or fs.promises.rm keeps the security class; fails_when=the delete rules read
// only one line at a time; seam=matchRisk over a file's added lines
const split = (lines: string[]) => matchRisk({ codePaths: ['src/clean.ts'], added: { 'src/clean.ts': lines }, removed: {}, scopeAuth: false }).has('security');
expect(split([' fs.rmSync(path.join(root, "build-output-cache"), {', ' recursive: true,', ' force: true,', ' });'])).toBe(true);
expect(split([' await fs.promises.rm(', ' path.join(root, "cache"),', ' { recursive: true },', ' );'])).toBe(true);
expect(split([' fs.rmSync(', ' dir,', ' /*' + 'x'.repeat(510) + '*/', ' { recursive: true },', ' );'])).toBe(true);
expect(split([' fs.rmSync(path.join(root, "file"), {', ' force: true,', ' });'])).toBe(false);
});
});
describe('svgDrawing in linear time', () => {
+2 -1
View File
@@ -64,7 +64,8 @@ describe('verifyRun', () => {
['run_stale', world({ run: { ...base.run, updated_at: '2026-10-28T10:00:00Z' } })],
['job_missing', world({ jobs: [{ name: 'lint', conclusion: 'success' }] })],
['job_ambiguous', world({ jobs: [{ name: 'full-suite', conclusion: 'success' }, { name: 'full-suite', conclusion: 'success' }] })],
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
['job_skipped', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'cancelled' }] })],
['artifact_missing', world({ artifact: new Error('no artifact named full-suite-tree') })],
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_id: 99 } })],
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_attempt: 2 } })],
+104
View File
@@ -0,0 +1,104 @@
/**
* setup: the skill_routing and skill_contracts gates (GST-1088, lane A). The
* _skill_hook_gate function is lifted out of setup and run against the real
* gstack-config and gstack-settings-hook with a temp state root and a temp
* settings.json: default off registers nothing, a flag persists and registers
* once with a one-time line, a typo warns and is not persisted, and off
* removes the registration. Static pins cover the flags and the parse gate.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ROOT, tmpDir } from './helpers/skill-contract-fixture';
const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const settingsHookSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), 'utf8');
const gateFn = setupSrc.slice(setupSrc.indexOf('_skill_hook_gate() {'), setupSrc.indexOf('\n}\n', setupSrc.indexOf('_skill_hook_gate() {')) + 3);
let state = '', home = '';
beforeEach(() => { state = tmpDir('gstack-gate-state-'); home = tmpDir('gstack-gate-home-'); });
afterEach(() => { for (const d of [state, home]) fs.rmSync(d, { recursive: true, force: true }); });
const settingsFile = () => path.join(home, 'settings.json');
const settings = () => (fs.existsSync(settingsFile()) ? JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) : {});
/** Run the routing gate once: mode is the flag value ("" = none). */
function gate(mode: string, env: Record<string, string> = {}, noTeam = 0): { code: number | null; out: string } {
const hook = path.join(ROOT, 'hosts', 'claude', 'hooks', 'skill-route-hook');
const script = [
'set -e',
'log() { printf "%s\\n" "$*"; }',
`GSTACK_CONFIG="${path.join(ROOT, 'bin', 'gstack-config')}"`,
`SETTINGS_HOOK="${path.join(ROOT, 'bin', 'gstack-settings-hook')}"`,
`IS_WINDOWS=0; NO_TEAM_MODE=${noTeam}`,
gateFn,
`_skill_hook_gate skill_routing "$1" GSTACK_SKILL_ROUTING UserPromptSubmit "${hook}" gstack-skill-route 5 "routing hint"`,
].join('\n');
const r = spawnSync('bash', ['-c', script, 'gate', mode], {
env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_STATE_ROOT: state, GSTACK_SETTINGS_FILE: settingsFile(), ...env },
encoding: 'utf8', timeout: 30_000,
});
return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
}
const config = () => (fs.existsSync(path.join(state, 'config.yaml')) ? fs.readFileSync(path.join(state, 'config.yaml'), 'utf8') : '');
const registered = () => (settings().hooks?.UserPromptSubmit ?? []) as Array<{ hooks: Array<{ type: string; command: string; timeout?: number }> }>;
describe('setup: skill hook gates', () => {
test('flags parse for both switches, and both appear in --help', () => {
for (const f of ['--skill-routing)', '--no-skill-routing)', '--skill-routing=*)', '--skill-contracts)', '--no-skill-contracts)', '--skill-contracts=*)']) {
expect(setupSrc).toContain(` ${f}`);
}
expect(setupSrc).toContain('--skill-routing --no-skill-routing --skill-contracts --no-skill-contracts');
expect(setupSrc).toContain('--skill-routing / --no-skill-routing');
expect(setupSrc).toContain('--skill-contracts / --no-skill-contracts');
});
test('both hooks resolve through _hook_command_path literals (the parse gate and hook-check see them), with KNOWN_HOOKS rows', () => {
expect(setupSrc).toContain('_hook_command_path hosts/claude/hooks/skill-route-hook ||');
expect(setupSrc).toContain('_hook_command_path hosts/claude/hooks/skill-contract-stop-hook ||');
expect(settingsHookSrc).toContain('"skill-route-hook": { source: "gstack-skill-route", event: "UserPromptSubmit", matcher: "", relpath: "hosts/claude/hooks/skill-route-hook" }');
expect(settingsHookSrc).toContain('"skill-contract-stop-hook": { source: "gstack-skill-contract", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/skill-contract-stop-hook" }');
expect(setupSrc).toMatch(/_skill_hook_gate skill_contracts "\$SKILL_CONTRACTS_MODE" GSTACK_SKILL_CONTRACTS Stop \\\n "\$SKILL_CONTRACT_HOOK" gstack-skill-contract 10/);
});
test('default off: nothing registered, nothing persisted', () => {
expect(gate('')).toEqual({ code: 0, out: '' });
expect(registered()).toEqual([]);
expect(config()).not.toContain('skill_routing');
});
test('flag on: persists, registers a schema-valid entry with a one-time line; a re-run is quiet', () => {
const first = gate('on');
expect(first.code).toBe(0);
expect(first.out).toContain('registered UserPromptSubmit hook: routing hint (on via flag; turn off: gstack-config set skill_routing off, or ./setup --no-skill-routing');
expect(config()).toMatch(/^skill_routing: on$/m);
const entries = registered();
expect(entries).toHaveLength(1);
expect(entries[0].hooks).toHaveLength(1);
expect(entries[0].hooks[0]).toMatchObject({ type: 'command', timeout: 5 });
expect(entries[0].hooks[0].command).toEndWith('hosts/claude/hooks/skill-route-hook');
expect(gate('')).toEqual({ code: 0, out: '' });
expect(registered()).toHaveLength(1);
});
test('env on registers without persisting; a typo warns, applies off and is not persisted', () => {
expect(gate('', { GSTACK_SKILL_ROUTING: 'yes' }).out).toContain('on via env GSTACK_SKILL_ROUTING');
expect(config()).not.toContain('skill_routing');
const typo = gate('onn');
expect(typo.out).toContain("WARNING: unrecognized skill_routing value 'onn' (from flag) — using default 'off' for this run, not persisting");
expect(config()).not.toContain('skill_routing');
expect(registered()).toEqual([]);
});
test('off removes a live registration, and so does --no-team', () => {
gate('on');
expect(registered()).toHaveLength(1);
gate('off');
expect(registered()).toEqual([]);
expect(config()).toMatch(/^skill_routing: off$/m);
gate('on');
gate('', {}, 1);
expect(registered()).toEqual([]);
});
});
+642
View File
@@ -0,0 +1,642 @@
/**
* CI-carried lanes end to end (GSTA-258 pieces 1, 2 and 4; design fixes A1-A4): gstack-ship-preflight, gstack-ship-release
* finish, push --early and accept, gstack-pregate's automatic --from-run and gstack-ci-carry, against a fixture repository
* whose gh is a fake serving one Actions world, so nothing reaches GitHub.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
import { findFilesBySuffix } from './helpers/scratch-repo';
import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(120_000);
const BIN = path.dirname(RELEASE);
const fixtures: ShipFx[] = [];
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
const FREE = "printf x >> ../free-runs; printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
const UNIT = "printf x >> ../unit-runs; printf ' 2 pass\\n 0 fail\\nRan 2 tests across 1 file. [1.00ms]\\n' >&2";
const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: 'Linux' };
const BASE = {
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } } }, null, 2) + '\n',
'.github/workflows/free.yml': 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n',
};
const fx = () => { const f = shipFixture(BASE); fixtures.push(f); return f; };
const runs = (f: ShipFx, name: string) => { const p = path.join(f.root, name); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').length : 0; };
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
/** One run of free.yml at HEAD (id 7 unless named), green with a tree.json naming HEAD's tree, unless overridden. */
function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<string, unknown>; others?: Array<Record<string, unknown>>; jobs?: Array<{ name: string; conclusion: string | null }>; attempts?: Record<string, Array<{ name: string; conclusion: string | null }>> } = {}): void {
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' }, ...over.run };
const w: FakeWorld = {
runs: [run, ...((over.others ?? []).map(o => ({ ...run, ...o })))] as FakeWorld['runs'],
jobs: { 7: (over.jobs ?? [{ name: 'suite', conclusion: 'success' }]).map(j => ({ ...j, completed_at: at(5) })), 9: [{ name: 'suite', conclusion: 'failure', completed_at: at(1) }],
...Object.fromEntries(Object.entries(over.attempts ?? {}).map(([k, js]) => [k, js.map(j => ({ ...j, completed_at: at(5) }))])) },
trees: { 7: { run_id: 7, run_attempt: 1, event: run.event, sha: head, ...(run.event === 'pull_request' ? { head_sha: head } : {}), tree, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 12, os: 'Linux', ...over.tree } },
};
const file = path.join(f.root, 'world.json');
fs.writeFileSync(file, JSON.stringify(w));
f.gh(fakeGhScript(file));
}
const preflight = (f: ShipFx, lanes = [`free=${FREE}`, `unit=${UNIT}`]) => f.run(PREFLIGHT, ['--tests-only', '--base', 'main', ...lanes.flatMap(l => ['--lane', l])]);
const lane = (out: string, label: string) => out.split('\n').find(l => l.startsWith(`LANE: ${label} `)) ?? '';
describe('Step 5 lanes: CI carry and the tree cache', () => {
// Value: protects=a lane the base maps is carried from CI at this exact tree and never runs; any other lane runs once and is reused at the same tree;
// fails_when=the free suite reruns locally beside a green CI run, or a passed lane reruns at an unchanged tree; why_new=GSTA-258 pieces 1-2; seam=fake gh, gstack-evidence ledger
test('the mapped lane is carried from the green run; the other lane runs once, then is reused', () => {
const f = fx();
world(f);
const first = preflight(f);
expect(first.status).toBe(0);
expect(lane(first.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
expect(lane(first.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+$/);
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
const again = preflight(f);
expect(lane(again.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+ reused=\S+$/);
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
const chk = f.run(path.join(BIN, 'gstack-evidence'), ['check', '--label', 'free', '--expect-cmd', FREE]);
expect(chk.stdout).toContain('source=ci runs=7/1');
});
// Value: protects=A2/A3 and verifyRun's refusals: a dirty tree, an uncovered inventory, a fork run and a newer red run never carry, and a label alone never maps;
// fails_when=any of them carries; seam=lib/ci-carry.ts findCarry through runLanes
test('a dirty tree, an uncovered inventory, a fork run, a newer red run or a changed command runs the lane here', () => {
const f = fx();
const cases: Array<[string, () => void, string[]?]> = [
['dirty_tree', () => { world(f); f.write({ 'src/b.ts': 'export const b = 3;\n' }); }],
['inventory_uncovered', () => { f.git('checkout', '--', 'src/b.ts'); world(f, { tree: { files: ['test/a.test.ts'] } }); }],
['fork_run', () => world(f, { run: { head_repository: { full_name: 'evil/fx' } } })],
['newer_run_vetoes', () => world(f, { others: [{ id: 9, conclusion: 'failure', created_at: at(2), run_started_at: at(2) }] })],
];
let expected = 0;
for (const [reason, setup] of cases) {
setup();
const out = preflight(f, [`free=${FREE}`]).stdout;
expect([reason, out]).toEqual([reason, expect.stringContaining('CI_CARRY: none label=free (')]);
expect(out).toContain(reason);
expect(lane(out, 'free')).toMatch(/^LANE: free pass exit=0 tests_ran=3 /);
expected += lane(out, 'free').includes('reused=') ? 0 : 1;
expect(runs(f, 'free-runs')).toBe(expected);
}
world(f);
const other = preflight(f, [`free=${FREE} `]).stdout;
expect(other).not.toContain('CI_CARRY');
expect(lane(other, 'free')).not.toContain('carried');
});
// Value: protects=a CI-sourced ledger row exists only for a run gstack-evidence record-ci found and verified itself (/ship pass 1 red-team#1);
// fails_when=caller-supplied run ids, test counts or a tree make a FRESH carried row, or an unmapped command or a missing run records one; seam=fake gh
test('record-ci verifies the run itself: forged facts, an unmapped command or no run at HEAD record nothing', () => {
const f = fx();
const ev = (args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
const forged = ev(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', f.git('rev-parse', 'HEAD^{tree}').stdout.trim(), '--tests', '999', '--finished', at(1), '--', FREE]);
expect(forged.status).toBe(2);
world(f, { run: { head_sha: '0'.repeat(40) } });
const noRun = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([noRun.status, noRun.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: no_run: /m)]);
world(f);
const unmapped = ev(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
expect([unmapped.status, unmapped.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
expect(ev(['check', '--label', 'free', '--expect-cmd', FREE]).stdout).not.toContain('EVIDENCE: FRESH');
expect(JSON.parse(ev(['carried', '--json']).stdout)).toEqual([]);
const ok = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files log=- source=ci runs=7/1 policy=origin/main@')]);
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
expect(row.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/pregate\.json$/);
});
});
describe('finish, push --early and accept (A1)', () => {
const finishArgs = (f: ShipFx, reviewed: string, post: string) => ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', `free=${FREE}`];
const snapshot = (f: ShipFx) => {
const out = path.join(f.root, 'post.json');
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
return out;
};
const unattended = { GSTACK_SESSION_KIND: 'unattended' };
// Value: protects=finish never runs a mapped lane locally on the stamped tree: it defers it to accept and runs only pregate's preflight stage; accept carries
// it from the green run at the pushed head and reruns the pregate there; fails_when=the stamped tree reruns the suite, or accept trusts a run at another tree;
// why_new=design fix A1; seam=fake gh, gstack-evidence, gstack-pregate
test('finish defers the mapped lane; accept carries it from the green run at the pushed head', () => {
const f = fx();
world(f);
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
const post = snapshot(f);
const pregateFile = path.join(f.work, '.gstack/tmp/pregate.json');
const before = fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null;
const fin = f.run(RELEASE, finishArgs(f, reviewed, post));
expect(fin.stdout).toMatch(/^EVIDENCE: DEFERRED label=free \(/m);
expect(lineOf(fin.stdout, 'PREGATE')).toMatch(/^PREGATE: preflight (pass|warn) on the stamped tree/);
expect(fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null).toBe(before);
expect(fin.stdout).toContain('gstack-ship-release accept --base main --lane');
expect(fin.status).toBe(0);
expect(runs(f, 'free-runs')).toBe(0);
const notPushed = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(notPushed.stdout).toContain('is not origin/feat: run gstack-ship-release push first');
const push = f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], unattended);
expect(lineOf(push.stdout, 'SECRETS')).toMatch(/^SECRETS: (pass|warn) \(early push\)$/);
expect(lineOf(push.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
world(f, { run: { status: 'in_progress', conclusion: null } });
const running = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(running.status).toBe(3);
expect(running.stdout).toContain('CI_WAIT: running ci/free run=7/1');
world(f, { run: { conclusion: 'failure' }, jobs: [{ name: 'suite', conclusion: 'failure' }] });
const red = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(red.stdout).toContain('STOP: free.yml run 7 failed (suite)');
world(f);
const ok = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(ok.stdout).toContain('CI_WAIT: green ci/free run=7/1');
expect(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=2 unit=files .* source=ci runs=7\/1$/);
expect(ok.stdout).toContain('CI_CARRY: accepted ci-carry tree=');
expect(ok.stdout).toContain('NEXT: Step 18');
expect(ok.status).toBe(0);
expect(runs(f, 'free-runs')).toBe(0);
const carried = JSON.parse(f.run(path.join(BIN, 'gstack-evidence'), ['carried', '--json']).stdout);
expect(carried).toEqual([{ lane: 'free', run: 7, attempt: 1, tree: f.git('rev-parse', 'HEAD^{tree}').stdout.trim() }]);
});
});
describe('gstack-pregate tests stage and gstack-ci-carry', () => {
// Value: protects=the tests stage carries CI at HEAD's clean tree on its own, and --no-ci-carry or a dirty tree turns it off; fails_when=pregate reruns what CI ran, or carries a dirty tree;
// why_new=GSTA-258 piece 1; seam=fake gh; no_claim=a small machine's remote gate obligation (covered by from-run's tests)
test('the tests stage accepts the run on its own, and --no-ci-carry or a dirty tree does not', () => {
const f = fx();
world(f);
const pregate = (extra: string[] = []) => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main', ...extra]);
expect(pregate().stderr).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} tests=12 runs=7\/1 profiles=ci\/free /m);
expect(pregate(['--no-ci-carry']).stderr).not.toContain('CI_CARRY');
f.write({ 'scratch.txt': 'x\n' });
expect(pregate().stderr).toContain('CI_CARRY: none (dirty_tree');
});
// Value: protects=the CLI finds and waits on the same evidence; fails_when=find or wait accepts a red run or prints no verdict
test('gstack-ci-carry find and wait', () => {
const f = fx();
world(f);
const cli = (args: string[]) => f.run(path.join(BIN, 'gstack-ci-carry'), args);
const found = cli(['find', '--base', 'main']);
expect([found.status, found.stdout]).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted ci-carry tree=/)]);
const mapped = cli(['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']);
expect(mapped.status).toBe(0);
expect(cli(['find', '--base', 'main', '--command', 'bun run test', '--allow-repo-commands']).stdout).toContain('CI_CARRY: none (not_mapped');
expect(cli(['wait', '--base', 'main', '--timeout', '0']).stdout).toContain('CI_WAIT: green ci/free run=7/1');
world(f, { run: { conclusion: 'failure' }, jobs: [{ name: 'suite', conclusion: 'failure' }] });
const red = cli(['wait', '--base', 'main', '--timeout', '0']);
expect([red.status, red.stdout.trim()]).toEqual([1, 'CI_WAIT: red ci/free run=7/1 failing=suite']);
expect(cli(['bogus']).status).toBe(2);
});
});
describe('GST-1186: pregate guards carry like lanes (founderstar shape: guards only, no lanes)', () => {
// Value: protects=a guard whose exact command the base's ship_lanes_from maps is carried from the verified CI run at this tree, and a guard
// whose exact command already passed at this tree in the ledger is reused, so guard-only repos stop rerunning suites CI or /ship already ran;
// fails_when=check-quick reruns beside a green CI run, play-through reruns after a same-tree ledger pass, or a dirty tree still carries;
// why_new=FOUN-74 (founderstar GST-1184): about 6 minutes of guard suites rerun per /ship; seam=gstack-evidence record-ci and passed; no_claim=CI
const script = (name: string, tests: number) => `printf x >> ../${name}-runs\nprintf ' ${tests} pass\\n 0 fail\\nRan ${tests} tests across 1 file. [1.00ms]\\n' >&2\n`;
const CHECK = 'bash tests/check.sh --quick';
const PLAY = 'bash tests/play-through.sh';
const guard = (id: string, command: string) => ({ id, when: ['src/**'], command, fix: `rerun ${id}` });
const FOUNDERSTAR = {
...BASE,
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [CHECK]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } },
guards: [guard('check-quick', CHECK), guard('play-through', PLAY), guard('assets', 'bash tests/assets.sh')] }, null, 2) + '\n',
'tests/check.sh': script('check', 4), 'tests/play-through.sh': script('play', 2), 'tests/assets.sh': script('assets', 1),
};
const guards = (f: ShipFx) => f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
const counts = (f: ShipFx) => [runs(f, 'check-runs'), runs(f, 'play-runs'), runs(f, 'assets-runs')];
test('check-quick carries from CI, play-through reuses a same-tree ledger pass, assets runs; a dirty tree carries nothing', () => {
const f = shipFixture(FOUNDERSTAR); fixtures.push(f);
world(f);
const first = guards(f);
expect(first.status, first.stdout + first.stderr).toBe(0);
expect(first.stdout).toContain('GUARD: check-quick carried runs=7/1');
expect(first.stdout).toContain('3 guard(s) passed: check-quick, play-through, assets (1 from an earlier pass at this tree)');
expect(counts(f)).toEqual([0, 1, 1]);
expect(f.run(path.join(BIN, 'gstack-evidence'), ['run', '--label', 'play', '--', PLAY]).status).toBe(0);
const second = guards(f);
expect(second.status).toBe(0);
expect(second.stdout).toMatch(/GUARD: play-through reused label=play source=local ts=\S+/);
expect(counts(f)).toEqual([0, 2, 2]);
f.write({ 'src/b.ts': 'export const b = 3;\n' });
const dirty = guards(f);
expect(dirty.stdout).toMatch(/CI_CARRY: none guard=check-quick \(dirty_tree/);
expect(dirty.stdout).not.toContain('reused');
expect(counts(f)).toEqual([1, 3, 3]);
});
});
describe('GST-1189: a lane carries by named CI jobs instead of an inventory', () => {
// Value: protects=a ship_lanes_from entry that declares { profiles, jobs } carries only when every named job succeeded in the newest attempt
// of the verified run at HEAD's tree, and refuses with a named reason otherwise; fails_when=a typecheck-only lane cannot carry (no test
// files to inventory), or carries beside a missing, skipped or older-attempt job, or another tree's run;
// why_new=gbrain run 41564913688 (tree 13570abc): verify and typecheck passed in CI but no inventory could name their files; seam=fake gh
const VERIFY = 'bun run verify';
const pregateJson = (entry: Record<string, unknown>) => JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], ...entry } } }, null, 2) + '\n';
const GBRAIN = { ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify', 'typecheck'] }) };
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'verify', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
const NO_FILES = { files: [], tests: 0 };
const gbrain = () => { const f = shipFixture(GBRAIN); fixtures.push(f); return f; };
const find = (f: ShipFx) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']);
test('every named job green at the same tree carries, with no test files; record-ci records the jobs', () => {
const f = gbrain();
world(f, { jobs: ALL, tree: NO_FILES });
const ok = find(f);
expect([ok.status, ok.stdout]).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/1 profiles=ci\/free /m)]);
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=n/a log=- source=ci runs=7/1')]);
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
expect([row.jobs, row.tests_ran]).toEqual([['verify', 'typecheck'], 'n/a']);
});
test('a missing job refuses as job_missing', () => {
const f = gbrain();
world(f, { jobs: ALL.filter(j => j.name !== 'typecheck'), tree: NO_FILES });
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_missing)');
});
test('a skipped job refuses as job_skipped', () => {
const f = gbrain();
world(f, { jobs: ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j), tree: NO_FILES });
const r = find(f);
expect([r.status, r.stdout]).toEqual([1, expect.stringContaining('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)')]);
});
test('only the newest attempt counts: a rerun that passed carries, a rerun that skipped the job does not', () => {
const f = gbrain();
const failedFirst = ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'failure' } : j);
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': failedFirst, '7/2': ALL } });
expect(find(f).stdout).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/2 /m);
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': ALL, '7/2': ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j) } });
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)');
});
test('a run that tested another tree refuses as tree_mismatch', () => {
const f = gbrain();
world(f, { jobs: ALL, tree: { ...NO_FILES, tree: '1'.repeat(40) } });
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: tree_mismatch)');
});
test('an entry declares exactly one of inventory or jobs', () => {
const both = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify'], inventory: 'printf x' }) }); fixtures.push(both);
world(both, { jobs: ALL, tree: NO_FILES });
expect(find(both).stdout).toContain('CI_CARRY: none (profiles_unavailable');
const neither = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({}) }); fixtures.push(neither);
world(neither, { jobs: ALL, tree: NO_FILES });
expect(find(neither).stdout).toContain('CI_CARRY: none (profiles_unavailable');
});
});
describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a mapping without carrying it', () => {
// Value: protects=a lane carries under an exact alias the base declares (never an implicit env-prefix rewrite); a narrow lane never reports
// the whole run's test count; an unknown key under a project floor above this gstack says to upgrade; --simulate proves a head map while
// writing and clearing nothing; carried never lists a row another base's policy mapped;
// fails_when=an env-prefixed lane reruns beside a green run, a 46-test lane records 9677, a proof run's rows land in the PR's own receipt,
// or a branch's own map carries; why_new=HARN-7 (harness-evals #240), ALPH-14 (alphaclaw #310), GEVA-235 (gstack-evals #180), GGST-15; seam=fake gh
const ev = (f: ShipFx, args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
const ledger = (f: ShipFx) => fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : [];
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
test('a declared alias carries like its key; an undeclared env prefix does not', () => {
const TESTS = 'bun test tests/';
const ALIAS = 'env -u ANTHROPIC_API_KEY bun test tests/';
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TESTS]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\n'", aliases: [ALIAS] } } }) + '\n' }); fixtures.push(f);
world(f);
const rec = ev(f, ['record-ci', '--label', 'tests', '--base', 'main', '--', ALIAS]);
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=1 unit=files log=- source=ci runs=7/1')]);
const find = (command: string) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', command, '--allow-repo-commands']).stdout;
expect(find(ALIAS)).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} files=1 /m);
expect(find('env -i HOME=/tmp bun test tests/')).toContain('CI_CARRY: none (not_mapped');
});
test('an alias that repeats a key is invalid config', () => {
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { a: { profiles: ['ci/free'], jobs: ['suite'] }, b: { profiles: ['ci/free'], jobs: ['suite'], aliases: ['a'] } } }) + '\n' }); fixtures.push(f);
world(f);
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main']).stdout).toContain('CI_CARRY: none (profiles_unavailable');
});
test('a carried narrow lane counts its covered inventory files, never the run total (alphaclaw: 46 tests recorded as 9677)', () => {
const f = fx();
world(f, { tree: { files: ['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts'], tests: 9677 } });
const rec = ev(f, ['record-ci', '--label', 'guards', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files log=- source=ci')]);
expect(rec.stderr).not.toContain('9677');
});
test('a jobs-mapped guard carries in the guards stage (harness-evals: bunx tsc --noEmit) and reports n/a', () => {
const TSC = 'bunx tsc --noEmit';
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } }, guards: [{ id: 'typecheck', when: ['src/**'], command: TSC, fix: 'bunx tsc --noEmit' }] }) + '\n' }); fixtures.push(f);
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
const g = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
expect([g.status, g.stdout]).toEqual([0, expect.stringContaining('GUARD: typecheck carried runs=7/1')]);
const row = JSON.parse(fs.readFileSync(ledger(f)[0]!, 'utf8').trim().split('\n').at(-1)!);
expect([row.tests_ran, row.jobs]).toEqual(['n/a', ['typecheck']]);
});
test('an unknown key under a project floor above this gstack says to upgrade; without the floor it stays the generic error', () => {
const conf = JSON.stringify({ future_lanes: {} }) + '\n';
const floor = shipFixture({}, { '.gstack/pregate.json': conf, '.gstack/ship-policy.json': JSON.stringify({ min_gstack_version: '99.0.0.0' }) + '\n' }); fixtures.push(floor);
const newer = floor.run(path.join(BIN, 'gstack-pregate'), ['validate']);
expect(newer.status).not.toBe(0);
expect(newer.stderr).toContain('needs gstack >= 99.0.0.0; upgrade');
expect(newer.stderr).toContain('(PREGATE_GSTACK_TOO_OLD)');
const plain = shipFixture({}, { '.gstack/pregate.json': conf }); fixtures.push(plain);
const typo = plain.run(path.join(BIN, 'gstack-pregate'), ['validate']);
expect([typo.stderr.includes('(PREGATE_CONFIG_INVALID)'), typo.stderr.includes('needs gstack')]).toEqual([true, false]);
});
test('--simulate proves the branch map: every line says SIMULATION, nothing is written, nothing clears, and head policy never carries', () => {
const VERIFY = 'bun run verify';
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n';
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
const sim = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `verify=${VERIFY}`, '--lane', `unit=${UNIT}`]);
const lines = sim.stdout.trim().split('\n');
expect(lines.every(l => l.startsWith('SIMULATION: ')), sim.stdout + sim.stderr).toBe(true);
expect(sim.stdout).toContain('SIMULATION: LANE: verify would carry run=7/1 tests_ran=n/a');
expect(sim.stdout).toContain('SIMULATION: LANE: unit not mapped');
expect([ledger(f), runs(f, 'unit-runs'), fs.existsSync(path.join(f.work, '.gstack', 'tmp', 'pregate.json'))]).toEqual([[], 0, false]);
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
const real = ev(f, ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
expect([real.status, real.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--simulate', '--lane', `verify=${VERIFY}`]).status).toBe(2);
});
test('carried lists only rows the receipt base mapped: a proof run against the PR branch never reaches its receipt', () => {
const f = fx();
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
world(f);
expect(ev(f, ['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
expect(JSON.parse(ev(f, ['carried', '--base', 'main', '--json']).stdout)).toEqual([]);
expect(JSON.parse(ev(f, ['carried', '--base', 'feat', '--json']).stdout)).toEqual([expect.objectContaining({ lane: 'free', run: 7, attempt: 1 })]);
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
});
});
describe('GST-1190: a file whose every test skipped on CI is not carried', () => {
// Value: protects=a lane never carries a test file that ran nothing on the CI runner (Docker images, drive paths, an installed gstack), so
// those tests run here instead of silently dropping; a partly skipped file and an older tree.json without skipped_files carry as before;
// fails_when=harness-evals' 75 runner-skipped tests carry as passed; why_new=HARN-7 (harness-evals #240); seam=fake gh
test('a fully skipped inventory file runs the lane here; a partly skipped one (not listed) and an older tree.json still carry', () => {
const f = fx();
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: inventory_skipped: 1 of 2 inventory file\(s\) skipped every test in CI and the lane declares no per_file runner: test\/b\.test\.ts$/m)]);
const out = preflight(f, [`free=${FREE}`]);
expect(out.stdout).toContain('CI_CARRY: none label=free (inventory_skipped');
expect(runs(f, 'free-runs')).toBe(1);
world(f, { tree: { skipped_files: [] } });
expect(f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]).status).toBe(0);
world(f, { tree: { skipped_files: 'test/b.test.ts' } });
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']).stdout).toContain('artifact_mismatch');
});
test('the tests stage runs a selected file the run skipped entirely, and accepts the rest', () => {
const conf = JSON.stringify({ runner: 'bun test {files}', dependencies: { 'src/**': ['test/a.test.ts', 'test/b.test.ts'] }, run_profiles: { 'ci/free': PROFILE } }) + '\n';
const t = "import { test, expect } from 'bun:test';\ntest('x', () => expect(1).toBe(1));\n";
const f = shipFixture({ ...BASE, '.gstack/pregate.json': conf, 'test/a.test.ts': t, 'test/b.test.ts': t }); fixtures.push(f);
const stage = () => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main']);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const skipped = stage();
expect(skipped.stdout + skipped.stderr).toMatch(/REUSE: accepted 1 of 2 selected local test file\(s\) from run .*; 1 still run \(1 skipped every test in the run\)/);
world(f);
expect(stage().stdout + stage().stderr).toMatch(/REUSE: accepted 2 of 2 selected local test file\(s\) from run .*; 0 still run/);
});
});
describe('GST-1190: a path-skipped CI job counts only under an unchanged gate', () => {
// Value: protects=a lane whose CI job the workflow skipped by path is not owed only when the gate job that decided the skip succeeded and the
// workflow and the gate's scripts are byte-identical to origin/<base>; otherwise the branch controlled the skip and the lane runs here;
// fails_when=a branch edits the workflow or the gate script to skip its own typecheck and the lane still passes; why_new=TRUT-7 (truther #1169); seam=fake gh
const TSC = 'bunx tsc --noEmit';
const SKIP_PROFILE = { ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', scripts: ['scripts/changes.sh'] } };
const WORKFLOW = 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n changes:\n runs-on: ubuntu-latest\n steps:\n - run: bash scripts/changes.sh\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n';
const SKIP_BASE = {
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': SKIP_PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
'.github/workflows/free.yml': WORKFLOW,
'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT"\n',
};
const JOBS = (gate: string) => [{ name: 'changes', conclusion: gate }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
const setup = (feat: Record<string, string> = { 'src/b.ts': 'export const b = 2;\n' }, gate = 'success', event = 'pull_request') => {
const f = shipFixture(SKIP_BASE, feat); fixtures.push(f);
world(f, { jobs: JOBS(gate), run: { event }, tree: { files: [], tests: 0, event } });
return f;
};
const lane = (f: ShipFx) => preflight(f, [`tsc=${TSC}`]);
test('a skip the unchanged gate decided is not owed', () => {
const f = setup();
const out = lane(f);
expect(out.stdout).toContain('LANE: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) run=7/1');
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
expect([row.tests_ran, row.not_owed]).toEqual(['n/a', 'CI skipped at this tree (gate job changes, workflow unchanged)']);
});
test('a branch that changed the workflow decided its own skip: the lane runs here', () => {
const f = setup({ '.github/workflows/free.yml': WORKFLOW + '# skip more\n' });
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('a branch that changed the gate script decided its own skip: the lane runs here', () => {
const f = setup({ 'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT" # always\n' });
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('a gate job that failed decided nothing: the lane runs here', () => {
const f = setup(undefined, 'failure');
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('only a pull_request run\'s skip counts: a push or dispatch gate diffs against the last push, not the PR base', () => {
const f = setup(undefined, 'success', 'workflow_dispatch');
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('gstack-ci-carry find --policy-from head --simulate checks the branch map, says SIMULATION on every line and writes nothing', () => {
const VERIFY = 'bun run verify';
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['suite'] } } }) + '\n';
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
world(f, { tree: { files: [], tests: 0 } });
const cli = (args: string[]) => f.run(path.join(BIN, 'gstack-ci-carry'), args);
const sim = cli(['find', '--base', 'main', '--policy-from', 'head', '--simulate', '--command', VERIFY, '--allow-repo-commands']);
expect(sim.status).toBe(0);
expect(sim.stdout.trim().split('\n').every(l => l.startsWith('SIMULATION: '))).toBe(true);
expect(sim.stdout).toMatch(/^SIMULATION: CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=suite /m);
expect(fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : []).toEqual([]);
expect(cli(['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']).stdout).toContain('CI_CARRY: none (not_mapped');
expect(cli(['find', '--base', 'main', '--simulate']).status).toBe(2);
expect(cli(['wait', '--base', 'main', '--policy-from', 'head', '--simulate']).status).toBe(2);
});
});
describe('GST-1190: a lane carries the covered files and runs only the skipped remainder with its per_file runner', () => {
// Value: protects=a fully skipped CI file runs here through the lane's declared per_file runner while the rest carries, a failing remainder
// carries nothing, and a lane with no runner fails closed by name; fails_when=gstack's 6 runner-skipped files make the whole free suite rerun,
// or a remainder failure still records a carried pass; why_new=HARN-7 decision (GSTA-258, per-file lane carry); seam=fake gh, gstack-evidence record-ci
const PER = (exit: number) => `printf '%s\\n' {files} >> ../perfile-args; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2; exit ${exit}`;
const withRunner = (exit: number) => ({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: PER(exit) } } }, null, 2) + '\n' });
const args = (f: ShipFx) => { const p = path.join(f.root, 'perfile-args'); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; };
const rows = (f: ShipFx) => findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl').flatMap(p => fs.readFileSync(p, 'utf8').trim().split('\n').map(l => JSON.parse(l)));
test('the covered file carries; only the skipped file runs here, and both rows are recorded', () => {
const f = shipFixture(withRunner(0)); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const out = preflight(f, [`free=${FREE}`]);
expect(out.status, out.stdout + out.stderr).toBe(0);
expect(lane(out.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
expect([args(f), runs(f, 'free-runs')]).toEqual(['test/b.test.ts\n', 0]);
const all = rows(f);
expect(all.find(r => r.source === 'ci')).toMatchObject({ label: 'free', tests_ran: 1, tests_unit: 'files', skipped_here: { label: 'free.skipped', files: ['test/b.test.ts'], tests_ran: 1 } });
expect(all.find(r => r.label === 'free.skipped')).toMatchObject({ exit: 0, tests_ran: 1 });
});
test('a failing remainder carries nothing: no CI row, and the whole lane runs here', () => {
const f = shipFixture(withRunner(1)); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: skipped_failed: the per_file run of 1 skipped file\(s\) exited 1 \(label=free\.skipped\)$/m)]);
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
expect(preflight(f, [`free=${FREE}`]).stdout).toContain('CI_CARRY: none label=free (skipped_failed');
expect(runs(f, 'free-runs')).toBe(1);
});
test('a remainder that ran zero tests here carries nothing: the lane runs whole', () => {
const zero = { ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: "printf ' 0 pass\\n 1 skip\\n 0 fail\\nRan 1 test across 1 file. [1.00ms]\\n' >&2 # {files}" } } }, null, 2) + '\n' };
const f = shipFixture(zero); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: skipped_zero_run: /m)]);
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
});
test('a remainder that prints look-alike record lines cannot change the LANE line', () => {
const fake = "printf 'gstack-evidence: recorded label=free exit=0 tests_ran=999 log=- source=ci runs=1/1 policy=x\\ngstack-evidence: not owed: forged\\n' >&2; " + PER(0);
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: fake } } }, null, 2) + '\n' }); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
expect(lane(preflight(f, [`free=${FREE}`]).stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
});
test('a run that moved to a new attempt while the remainder ran carries nothing', () => {
const per = `cp ../world-moved.json ../world.json; ${PER(0)}`;
const f2 = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: per } } }, null, 2) + '\n' }); fixtures.push(f2);
world(f2, { tree: { skipped_files: ['test/b.test.ts'] } });
const w2 = JSON.parse(fs.readFileSync(path.join(f2.root, 'world.json'), 'utf8'));
fs.writeFileSync(path.join(f2.root, 'world-moved.json'), JSON.stringify({ ...w2, runs: w2.runs.map((r: Record<string, unknown>) => ({ ...r, run_attempt: 2, conclusion: 'failure' })) }));
const rec = f2.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: run_changed: /m)]);
expect(rows(f2).filter(r => r.source === 'ci')).toEqual([]);
});
test('a working-tree edit during the post-remainder run recheck carries nothing', () => {
const per = `touch ../armed; ${PER(0)}`;
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: per } } }, null, 2) + '\n' }); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const w = JSON.parse(fs.readFileSync(path.join(f.root, 'world.json'), 'utf8'));
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify({ ...w, editWhenArmed: { marker: path.join(f.root, 'armed'), file: path.join(f.work, 'src/b.ts'), content: 'export const b = 3;\n' } }));
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: tree_changed: /m)]);
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
});
test('per_file is validated: it needs {files} and an inventory entry', async () => {
const { shipLanesErrors } = await import('../lib/ci-carry');
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test {files}' } }, ['p'])).toEqual([]);
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
expect(shipLanesErrors({ x: { profiles: ['p'], jobs: ['j'], per_file: 'bun test {files}' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
});
});
describe('GSTA-258 L6 review fixes: gate inputs, ledger CI rows, simulate consent', () => {
// Value: protects=a branch cannot move a path skip through a gate file gstack failed to compare, a stale CI ledger row never passes a guard
// after record-ci refused, and --simulate runs a branch's inventory only with --allow-repo-commands; fails_when=the l6c-p1 reproductions pass
// again; why_new=l6c-p1 findings (readers 15 and 16); seam=fake gh, gstack-evidence
const TSC = 'bunx tsc --noEmit';
const flow = (gateJob: string, before = '') => `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n${before} changes:\n runs-on: ubuntu-latest\n${gateJob} suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n`;
const profile = (scripts: string[] | undefined) => ({ ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', ...(scripts ? { scripts } : {}) } });
const base = (workflow: string, extra: Record<string, string>, scripts: string[] | undefined = ['scripts/changes.sh']) => ({
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': profile(scripts) }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
'.github/workflows/free.yml': workflow, 'scripts/changes.sh': 'echo typecheck=false >> "$GITHUB_OUTPUT"\n', ...extra,
});
const JOBS = [{ name: 'changes', conclusion: 'success' }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
const tsc = (b: Record<string, string>, feat: Record<string, string>) => { const f = shipFixture(b, feat); fixtures.push(f); world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } }); return preflight(f, [`tsc=${TSC}`]).stdout; };
const REFUSED = 'CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)';
test('a gate script reached through ${{ github.workspace }} is compared', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash ${{ github.workspace }}/scripts/gate.sh\n'), { 'scripts/gate.sh': 'exit 0\n' });
expect(tsc(b, { 'scripts/gate.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a gate script under the job\'s working-directory is compared', () => {
const b = base(flow(' defaults:\n run:\n working-directory: tools\n steps:\n - run: bash ../scripts/changes.sh && bash gate2.sh\n'), { 'tools/gate2.sh': 'exit 0\n' });
expect(tsc(b, { 'tools/gate2.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a gate that runs a package script compares package.json', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bun run detect\n'), { 'package.json': '{"scripts":{"detect":"true"}}\n' });
expect(tsc(b, { 'package.json': '{"scripts":{"detect":"echo typecheck=false"}}\n' })).toContain(REFUSED);
});
test('the gate is the job, not a same-named step in an earlier job', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash scripts/extra.sh\n', ' lint:\n runs-on: ubuntu-latest\n steps:\n - run: echo lint\n name: changes\n'), { 'scripts/extra.sh': 'exit 0\n' });
expect(tsc(b, { 'scripts/extra.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a guard whose CI job the unchanged gate skipped says not owed, never carried', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh\n'), {});
const conf = JSON.parse(b['.gstack/pregate.json']);
b['.gstack/pregate.json'] = JSON.stringify({ ...conf, guards: [{ id: 'tsc', when: ['src/**'], command: TSC }] }) + '\n';
const f = shipFixture(b, { 'src/b.ts': 'export const b = 2;\n' }); fixtures.push(f);
world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } });
const out = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
expect(out.stdout, out.stdout + out.stderr).toContain('GUARD: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) runs=7/1');
});
test('a skippable profile must declare its gate scripts', async () => {
const { runProfileErrors } = await import('../lib/run-evidence');
expect(runProfileErrors({ p: profile(undefined) }, undefined)).toEqual([expect.objectContaining({ path: 'run_profiles["p"].skippable' })]);
expect(runProfileErrors({ p: profile(['scripts/changes.sh']) }, undefined)).toEqual([]);
});
test('passed never counts a CI row: a carry under another base or after a refusal does not pass a guard', () => {
const f = fx();
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
world(f);
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
expect(ev(['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
const p = ev(['passed', '--', FREE]);
expect([p.status, p.stdout]).toEqual([1, expect.stringMatching(/^NOT_PASSED: /m)]);
});
test('passed: a newer failure of the same command at this tree vetoes the older pass', () => {
const f = fx();
const CMD = "test -e ../fail-flag && exit 1; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2";
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(0);
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^PASSED: label=g source=local /m);
fs.writeFileSync(path.join(f.root, 'fail-flag'), '');
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(1);
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^NOT_PASSED: /m);
});
test('--simulate runs a branch inventory only with --allow-repo-commands', () => {
const INV = "printf x >> ../inv-runs; printf 'test/a.test.ts\\ntest/b.test.ts\\n'";
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: INV } } }) + '\n';
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
world(f);
const sim = (extra: string[]) => f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `free=${FREE}`, ...extra]);
const no = sim([]);
expect([no.status, runs(f, 'inv-runs')]).toEqual([1, 0]);
expect(no.stdout).toContain('pass --allow-repo-commands');
const yes = sim(['--allow-repo-commands']);
expect([yes.status, runs(f, 'inv-runs')], yes.stdout).toEqual([0, 1]);
expect(yes.stdout).toContain('SIMULATION: LANE: free would carry run=7/1 tests_ran=2 unit=files');
});
});
+44
View File
@@ -57,6 +57,50 @@ describe('gstack-ship-preflight', () => {
expect(r.status).toBe(0);
});
// Value: protects=a failed lookup of the remote's HEAD stops with "restore access first" instead of guessing the default branch from a local origin/HEAD;
// fails_when=a failed ls-remote falls back to the local ref, so the default branch with no local origin/HEAD merges another base and goes on, and a feature
// branch a Capy checkout's origin/HEAD names is refused as the base branch; why_new=GST-511 follow-up from #3718; seam=a git wrapper on PATH that fails
// ls-remote only; no_claim=which access failure it was
test('a failed remote HEAD lookup stops before merging, even when the fetch works', () => {
const f = fx();
fs.writeFileSync(path.join(f.root, 'stub', 'git'), `#!/bin/sh\n[ "$1" = ls-remote ] && { echo "ls-remote: unreachable" >&2; exit 128; }\nexec ${Bun.which('git')} "$@"\n`, { mode: 0o755 });
f.git('push', '-q', 'origin', 'main:release');
f.git('checkout', '-q', 'main');
f.git('remote', 'set-head', 'origin', '-d');
const onDefault = f.run(PREFLIGHT, ['--base', 'release']);
f.git('checkout', '-q', 'feat');
f.git('update-ref', 'refs/remotes/origin/feat', 'HEAD');
f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feat');
const onFeature = f.run(PREFLIGHT, ['--base', 'main']);
for (const r of [onDefault, onFeature]) {
expect(r.status).toBe(3);
expect(r.stdout).toContain('STOP: git ls-remote origin HEAD failed: ls-remote: unreachable; restore access first');
expect(r.stdout).not.toContain('MERGE:');
}
});
// Value: protects=a remote whose HEAD names no branch (detached, or a server that sends no symref) falls back to the default-base ladder, not the local origin/HEAD;
// fails_when=the fallback reads the local ref, so the default branch with no local origin/HEAD merges another base and goes on, and a feature branch a
// Capy checkout's origin/HEAD names is refused as the base branch; why_new=pass-1 review of the ls-remote stop; seam=a git wrapper whose ls-remote prints
// only a sha; no_claim=a repository whose default is neither main nor master
test('with no symbolic remote HEAD, the default branch still stops and a feature branch named by a local origin/HEAD goes on', () => {
const f = fx();
fs.writeFileSync(path.join(f.root, 'stub', 'git'), `#!/bin/sh\n[ "$1" = ls-remote ] && { echo "0000000000000000000000000000000000000000\tHEAD"; exit 0; }\nexec ${Bun.which('git')} "$@"\n`, { mode: 0o755 });
f.git('push', '-q', 'origin', 'main:release');
f.git('checkout', '-q', 'main');
f.git('remote', 'set-head', 'origin', '-d');
const onDefault = f.run(PREFLIGHT, ['--base', 'release']);
expect(onDefault.status).toBe(3);
expect(onDefault.stdout).toContain('STOP: on the base branch (main)');
expect(onDefault.stdout).not.toContain('MERGE:');
f.git('checkout', '-q', 'feat');
f.git('update-ref', 'refs/remotes/origin/feat', 'HEAD');
f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feat');
const onFeature = f.run(PREFLIGHT, ['--base', 'main']);
expect(onFeature.stdout).not.toContain('STOP: on the base branch');
expect(onFeature.status).toBe(0);
});
// Value: protects=Step 3 merges the base and a conflict stops with the files named, leaving the merge for the agent to resolve; fails_when=the bin resolves a conflict itself, aborts the merge, or continues to tests;
// why_new=moved from the Step 3 prose; seam=git merge; no_claim=which side is right
test('a merged base is reported; a conflict stops with the conflicted files and the merge left in place', () => {
+142 -3
View File
@@ -8,7 +8,7 @@ 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 { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
import { extractReceipt, landedStackParent, renderReceiptBlock } from '../lib/ship-receipt';
import { bindShippedReceipt } from '../lib/ship-receipt-depth';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
@@ -21,7 +21,7 @@ const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n";
const RETRY4 = "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n";
/** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */
function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/util.js': SUB }, childFiles: Record<string, string> = { 'lib/retry.js': RETRY4 }): { parent: string; child: string; body: string } {
function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/util.js': SUB }, childFiles: Record<string, string> = { 'lib/retry.js': RETRY4 }, base = 'parent'): { parent: string; child: string; body: string } {
repo.git('checkout', '-q', '-b', 'parent');
const parent = commit(repo, parentFiles, 'parent: sub');
repo.git('push', '-q', 'origin', 'parent');
@@ -29,7 +29,7 @@ function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/u
repo.git('reset', '-q', '--hard', parent);
const child = commit(repo, childFiles, 'child: four retries');
const body = path.join(repo.root, 'body.md');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'parent', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', base, '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
expect(w.status).toBe(EXIT.ok);
return { parent, child, body };
}
@@ -166,3 +166,142 @@ describe('carry after a stack parent squash-merges (#3570)', () => {
expect(receiptIn(body).head).toBe(child);
}, 90_000);
});
const CONFIG = ['a = 1', 'b = 2', 'c = 3', 'd = 4', 'e = 5', 'f = 6', 'g = 7', 'h = 8', ''].join('\n');
const configWith = (edits: Record<string, string>) => CONFIG.split('\n').map(l => edits[l] ?? l).join('\n');
describe('carry after a stack parent squash-merges, receipt written against main (GST-1199)', () => {
// Value: protects=a child PR stacked on its parent but targeting main keeps its receipt once the parent squash-merges and
// main is merged in (gstack-evals#178 faced a 45-60M-token gate re-review for it); fails_when=carry compares the child's
// whole range from the old fork point, parent hunks included, with HEAD's diff against a base that now holds them;
// seam=none; no_claim=gh pr edit (--body stands in)
test('the child carries when its range from the landed parent is HEAD\'s whole product diff against main', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { parent, child, body } = stack(repo, undefined, undefined, 'main');
landOnMain(repo, { 'lib/util.js': SUB });
const head = repo.git('rev-parse', 'HEAD');
const out = carry(repo, body);
expect(out.err).not.toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(out.status).toBe(EXIT.ok);
expect(out.out).toContain(`CARRY: carried from ${child.slice(0, 12)} to ${head.slice(0, 12)}`);
const r = receiptIn(body);
expect(r.head).toBe(head);
expect(r.base).toBe('main');
expect(r.carry!.reason).toBe(`stack parent ${parent.slice(0, 12)} landed + base merge`);
expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '8', [head]).ok).toBe(true);
}, 90_000);
// Value: protects=fail closed: a product edit after the receipt, or a parent that never landed and whose change the
// child's merge dropped, takes a fresh /ship; fails_when=any reviewed ancestor counts as a landed parent, or the edit
// hides behind the shorter range; seam=none; no_claim=-
test('a product edit after the receipt, or a dropped parent that never landed, refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { child, body } = stack(repo, undefined, undefined, 'main');
landOnMain(repo, { 'lib/util.js': SUB });
commit(repo, { 'lib/retry.js': RETRY4.replace('4', '5') }, 'child: five');
const edited = carry(repo, body);
expect(edited.status).toBe(EXIT.refused);
expect(edited.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(body).head).toBe(child);
const other = makeFixtureRepo(); repos.push(other);
const s = stack(other, undefined, undefined, 'main');
landOnMain(other, {});
commit(other, { 'lib/util.js': other.git('show', 'origin/main:lib/util.js') + '\n' }, 'drop the parent change');
const dropped = carry(other, s.body);
expect(dropped.status).toBe(EXIT.refused);
expect(dropped.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(s.body).head).toBe(s.child);
}, 120_000);
// Value: protects=a base change next to the child's reviewed hunks gets one fix-check reviewer, not a carry and not a full
// /ship; fails_when=the landed-parent comparison ignores base context or refuses it outright; seam=none; no_claim=-
test('a base change next to the child\'s reviewed hunks routes to RECEIPT_CARRY_SCOPED_REVIEW', () => {
const repo = makeFixtureRepo({ baseFiles: { 'lib/config.js': CONFIG } }); repos.push(repo);
const { child, body } = stack(repo, undefined, { 'lib/config.js': configWith({ 'd = 4': 'd = 40' }) }, 'main');
landOnMain(repo, { 'lib/util.js': SUB, 'lib/config.js': configWith({ 'f = 6': 'f = 60' }) });
const out = carry(repo, body);
expect(out.status).toBe(EXIT.refused);
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)');
expect(out.err).toContain('lib/config.js');
expect(receiptIn(body).head).toBe(child);
}, 90_000);
// Value: protects=the receipt never names a stack parent that does not exist; fails_when=a test-first commit with no
// product change counts as landed (any base holds it), so the reason claims a parent and the test range shrinks; seam=none;
// no_claim=-
test('a test-only commit in a branch that was never stacked is no landed parent', () => {
const repo = makeFixtureRepo({ baseFiles: { 'lib/config.js': CONFIG } }); repos.push(repo);
const fork = repo.git('rev-parse', 'HEAD');
commit(repo, { 'test/config.test.js': "const test = require('node:test');\ntest('d', () => {});\n" }, 'test first');
const reviewed = commit(repo, { 'lib/config.js': configWith({ 'd = 4': 'd = 40' }) }, 'fix d');
repo.git('checkout', '-q', 'main');
const mainTip = commit(repo, { 'docs/README.md': '# main moves on\n' }, 'main moves on');
expect(landedStackParent(repo.work, { rangeBase: fork, reviewed, currentBase: mainTip }, ['.', ':(exclude,glob)test/**'])).toBeNull();
}, 60_000);
});
describe('carry after a stack parent lands, the child having merged main before its receipt (GST-1214)', () => {
/** main <- parent (lib/util.js) <- feature (lib/retry.js), which merges main (wiki/) once before its receipt is written against main. */
function mergedStack(repo: FixtureRepo, mainFiles: Record<string, string | null> = { 'wiki/a.md': '# a\n', 'wiki/b.md': '# b\n' }): { parent: string; reviewed: string; body: string } {
repo.git('checkout', '-q', '-b', 'parent');
const parent = commit(repo, { 'lib/util.js': SUB }, 'parent: sub');
repo.git('push', '-q', 'origin', 'parent');
repo.git('checkout', '-q', 'feature');
repo.git('reset', '-q', '--hard', parent);
commit(repo, { 'lib/retry.js': RETRY4 }, 'child: four retries');
repo.git('checkout', '-q', 'main');
commit(repo, mainFiles, 'main: wiki');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--no-edit', 'origin/main');
const reviewed = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
expect(w.status).toBe(EXIT.ok);
return { parent, reviewed, body };
}
// Value: protects=a stacked child that merged main before its receipt keeps it once the parent squash-lands and main is
// merged again (gstack-evals#178 refused on 20 wiki/ paths its first base merge brought in); fails_when=the range from the
// landed parent counts paths that hold main's content at the reviewed head; seam=none; no_claim=gh pr edit (--body stands in)
test('the child carries when only its earlier base merge separates the parent range from HEAD\'s diff against main', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { parent, reviewed, body } = mergedStack(repo);
landOnMain(repo, { 'lib/util.js': SUB });
const head = repo.git('rev-parse', 'HEAD');
const out = carry(repo, body);
expect(out.err).not.toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(out.status).toBe(EXIT.ok);
expect(out.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)}`);
expect(receiptIn(body).carry!.reason).toBe(`stack parent ${parent.slice(0, 12)} landed + base merge`);
}, 90_000);
// Value: protects=fail closed: an edit to a file the earlier base merge brought in is product and refuses; fails_when=every
// base-merged path drops out of both sides, the child's own edit included; seam=none; no_claim=-
test('an edit to a base-merged file after the receipt refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { reviewed, body } = mergedStack(repo);
landOnMain(repo, { 'lib/util.js': SUB });
commit(repo, { 'wiki/a.md': '# a, edited by the child\n' }, 'child: edit wiki');
const out = carry(repo, body);
expect(out.status).toBe(EXIT.refused);
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(out.err).toContain('wiki/a.md');
expect(receiptIn(body).head).toBe(reviewed);
}, 90_000);
// Value: protects=fail closed when the earlier base merge turned a file into a directory: leaving out the old file must not
// hide an edit beneath it (a pathspec names a path and everything under it); fails_when=the exclusion covers a changed
// descendant; seam=none; no_claim=-
test('a base merge that replaced a file with a directory never hides an edit beneath it', () => {
const repo = makeFixtureRepo({ baseFiles: { services: 'old\n' } }); repos.push(repo);
const { reviewed, body } = mergedStack(repo, { services: null, 'services/worker.js': 'exports.allowed = false;\n' });
landOnMain(repo, { 'lib/util.js': SUB });
commit(repo, { 'services/worker.js': 'exports.allowed = true;\n' }, 'child: allow');
const out = carry(repo, body);
expect(out.status).toBe(EXIT.refused);
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(body).head).toBe(reviewed);
}, 90_000);
});
+16 -7
View File
@@ -309,25 +309,34 @@ describe('gstack-ship-release push: branch names', () => {
});
describe('GST-1031: a non-behavioral harness re-pin after review is release metadata', () => {
// Value: protects=a scripts/harness-version.json edit after review that only records changed harness files as non-behavioral at the same
// Value: protects=a scripts/harness-pins/<path>.json edit after review that only records changed harness files as non-behavioral at the same
// version routes as metadata, so a clean round 2 is not sent back through 5-11.5, while a version bump, a pattern change or an entry
// without its reason stays behavior; fails_when=the re-pin stops finish, or a bump or bare re-pin routes as metadata;
// why_new=GSTA-150 GST-1027 (icm_01M6ERWWBZ3VWXZY07F2W5ZSFB); seam=route; no_claim=that the recorded reason is true
const pin = (version: number, entry: Record<string, unknown>, patterns = ['test/helpers/x.ts']) => JSON.stringify({ version, patterns, files: { 'test/helpers/x.ts': entry } }) + '\n';
const finish = (after: string) => {
const PIN = 'scripts/harness-pins/test/helpers/x.ts.json';
const pin = (version: number, entry: Record<string, unknown>, patterns = ['test/helpers/x.ts']) => ({
'scripts/harness-version.json': JSON.stringify({ version, patterns }) + '\n',
[PIN]: JSON.stringify(entry) + '\n',
});
const finish = (after: Record<string, string>) => {
const f = fx();
f.write({ 'scripts/harness-version.json': pin(6, { blob: 'a'.repeat(40), since: 6 }) }); f.commit('pin');
f.write(pin(6, { blob: 'a'.repeat(40), since: 6 })); f.commit('pin');
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'scripts/harness-version.json': after }); f.commit('re-pin');
f.write(after); f.commit('re-pin');
return f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]).stdout;
};
test('a non-behavioral re-pin is metadata; a bump, a pattern change or an entry without its reason is behavior', () => {
const repin = finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }));
expect(lineOf(repin, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
expect(repin).not.toContain('STOP: behavior');
for (const after of [pin(7, { blob: 'b'.repeat(40), since: 7 }), pin(6, { blob: 'b'.repeat(40), since: 6 }), pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }, ['test/helpers/**'])])
expect(finish(after)).toContain('STOP: behavior, tests or build inputs changed since review (scripts/harness-version.json)');
expect(finish(pin(7, { blob: 'b'.repeat(40), since: 7 }))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN},scripts/harness-version.json)`);
expect(finish(pin(6, { blob: 'b'.repeat(40), since: 6 }))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN})`);
expect(finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }, ['test/helpers/**']))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN},scripts/harness-version.json)`);
});
test('the manifest regaining a files table is behavior', () => {
const f = { 'scripts/harness-version.json': JSON.stringify({ version: 6, patterns: ['test/helpers/x.ts'], files: {} }) + '\n' };
expect(finish(f)).toContain('STOP: behavior, tests or build inputs changed since review (scripts/harness-version.json)');
});
});
+130
View File
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { comparePath } from '../lib/ship-patch';
import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy';
import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt';
import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
@@ -248,6 +249,55 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () =>
const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]);
expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]);
});
// Value: protects=with a kept VERSION claim, the restamp's move of the branch's own fork-version row routes as metadata,
// and a base row moved the same way still stops (GST-980); fails_when=stampedFiles returns nothing at equal versions
// and finish routes the row as behavior; seam=lib/ship-bin-common.ts stampedFiles
test('a kept VERSION claim: the moved fork-version row is metadata; a moved base row stops (GST-980)', () => {
const kept = (rows: [string, string][]) => {
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.0.0', 'base']) },
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.0.0', 'base'], ['1.0.0.0', 'mine']) });
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'src/notice.ts': notice(...rows) }); f.commit('stamp, VERSION kept');
return finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
};
const own = kept([['0.9.0.0', 'old'], ['1.0.0.0', 'base'], ['1.0.1.0', 'mine']]);
expect(lineOf(own.stdout, 'ROUTE')).toEqual(expect.stringMatching(/ behavior=0 docs=0 metadata=1 tests=0 /));
const base = kept([['0.9.0.0', 'old'], ['1.0.1.0', 'base'], ['1.0.0.0', 'mine']]);
expect([base.status, lineOf(base.stdout, 'STOP')]).toEqual([3, behaviorStop]);
});
// Value: protects=gbrain's restamp of the PR's own `since: 'TBD'` row to the new version routes as metadata when the
// policy declares TBD in stamp_placeholders (GST-1026, gbrain #8444); an undeclared placeholder, a base row moved the
// same way, a duplicated base copy, or a line no stamp_pattern matches still stops; fails_when=stampedFiles reads the
// move as behavior (gbrain #8444) or accepts it off the branch's own rows; seam=lib/ship-bin-common.ts stampedFiles
test('a declared placeholder on the branch row is metadata; undeclared, a base row or no template stops (GST-1026)', () => {
const declared = (placeholders: string[] | undefined, templates = ["since: '{version}'"]) => JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: templates,
...(placeholders ? { stamp_placeholders: placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) });
const restamped = (pol: string, baseRows: [string, string][], ownRows: [string, string][], rows: [string, string][]) => {
const f = fx({ '.gstack/ship-policy.json': pol, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(...baseRows) },
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(...baseRows, ...ownRows) });
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(...rows) }); f.commit('restamp: TBD rows to 1.0.2.0');
return finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
};
const base: [string, string][] = [['0.9.0.0', 'old'], ['TBD', 'base']];
const metadata = / behavior=0 docs=0 metadata=2 tests=0 /;
const own = restamped(declared(['TBD']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
expect(lineOf(own.stdout, 'ROUTE'), own.stdout).toEqual(expect.stringMatching(metadata));
const undeclared = restamped(declared(undefined), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
expect([undeclared.status, lineOf(undeclared.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const other = restamped(declared(['WIP']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
expect([other.status, lineOf(other.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const baseRow = restamped(declared(['TBD']), base, [['TBD', 'mine']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['1.0.2.0', 'mine']]);
expect([baseRow.status, lineOf(baseRow.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const dup = restamped(declared(['TBD']), base, [['TBD', 'base']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['TBD', 'base']]);
expect([dup.status, lineOf(dup.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const noTemplate = restamped(declared(['TBD'], ['version: {version}']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]);
expect([noTemplate.status, lineOf(noTemplate.stdout, 'STOP')]).toEqual([3, behaviorStop]);
});
});
describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => {
@@ -739,3 +789,83 @@ describe('finish with an owner-accepted edit after a fix-check (GST-965, GBRA-27
});
});
// Value: protects=a granted fix-check's clean read clears the paths it reviewed at Step 16, a gate-edited test included, so
// finish does not send them back through Steps 5–11.5 (GEVA-218, gstack-evals#168); fails_when=finish routes a path inside
// the fix-check's range as behavior, counts a path edited after the fix-check, or counts a row that is not a completed clean
// fix-check with a range base; seam=finish's route reading the review log; no_claim=Step 11.5's binding
describe('finish after a completed fix-check (GST-1130, GEVA-218)', () => {
const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); });\n";
const TEST_V2 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); }, 1_000);\ntest('b finishes fast', () => { expect(1 + 1).toBe(2); }, 1_000);\n";
const logFixCheck = (f: ShipFx, row: Record<string, unknown>) => {
const start = f.run(path.join(BIN, 'gstack-review-log'), ['--start', 'review-fix-check']);
expect(start.status).toBe(0);
return f.run(path.join(BIN, 'gstack-review-log'), [JSON.stringify({ skill: 'review-fix-check', timestamp: '2026-11-02T00:00:00Z', via: 'ship', review_scope: 'frozen', status: 'clean', issues_found: 0, critical: 0, ...row }), '--finish', start.stdout.trim()]);
};
const grant = (f: ShipFx, stopHead: string) => {
const a = f.run(path.join(BIN, 'gstack-approvals'), ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${stopHead.slice(0, 12)})`, '--words', 'one scoped fix-check after round 2', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { GSTACK_APPROVALS_MIRROR: '0' });
expect(a.status).toBe(0);
return JSON.parse(a.stdout).added[0] as string;
};
const afterFixCheck = () => {
const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': TEST_V1 });
const passHead = f.git('rev-parse', 'HEAD').stdout.trim();
const reviewed = wtree(f);
f.write({ 'src/x.ts': 'export const x = 3;\n', 'test/x.test.ts': TEST_V2 }); f.commit('fix the round-2 blocker and bound its case');
return { f, passHead, fixHead: f.git('rev-parse', 'HEAD').stdout.trim(), reviewed, post: snapshot(f) };
};
test('paths inside a completed clean fix-check range count as reviewed, a gate-edited test included; a later edit still stops', () => {
const { f, passHead, fixHead, reviewed, post } = afterFixCheck();
const plain = finish(f, reviewed, post);
expect([plain.status, plain.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]);
const id = grant(f, passHead);
expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0);
const r = finish(f, reviewed, post);
expect(lineOf(r.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)}, grant ${id})`);
expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=unknown context=0 fix_checked=2$/);
expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]);
f.write({ 'src/x.ts': 'export const x = 4;\n' }); f.commit('an edit after the fix-check');
const late = finish(f, reviewed, post);
expect(lineOf(late.stdout, 'FIX_CHECK')).toStartWith('FIX_CHECK: reviewed test/x.test.ts (');
expect([late.status, late.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts)')]).toEqual([3, true]);
});
test('a clean fix-check row with no recorded review_loop_grant for a stop head in its range is not counted', () => {
const { f, passHead, reviewed, post } = afterFixCheck();
expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0);
const ungranted = finish(f, reviewed, post);
expect(lineOf(ungranted.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit)');
expect([ungranted.status, ungranted.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]);
grant(f, mergeBase(f));
const elsewhere = finish(f, reviewed, post);
expect([elsewhere.status, lineOf(elsewhere.stdout, 'FIX_CHECK')]).toEqual([3, 'FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit)']);
});
test('a fix-check row that did not finish clean, or names no range base, is not counted', () => {
const { f, passHead, reviewed, post } = afterFixCheck();
expect(logFixCheck(f, { completed: false, converged: false, range_base: passHead }).status).toBe(0);
const incomplete = finish(f, reviewed, post);
expect(lineOf(incomplete.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (missing start capture or incomplete/nonconverged pass)');
expect([incomplete.status, incomplete.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]);
expect(logFixCheck(f, { completed: true, converged: true }).status).toBe(0);
const unranged = finish(f, reviewed, post);
expect(lineOf(unranged.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (the row names no range_base commit on this branch)');
expect(unranged.status).toBe(3);
const fork = mergeBase(f);
expect(logFixCheck(f, { completed: true, converged: true, range_base: fork }).status).toBe(0);
const early = finish(f, reviewed, post);
expect(lineOf(early.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: not counted (range_base ${fork.slice(0, 12)} does not hold the reviewed tree)`);
expect(early.status).toBe(3);
});
test('a fix-check logged on uncommitted content is not counted', () => {
const { f, passHead, reviewed, post } = afterFixCheck();
f.write({ 'src/x.ts': 'export const x = 9;\n' });
expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0);
f.git('checkout', '--', 'src/x.ts');
const r = finish(f, reviewed, post);
expect(lineOf(r.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (the row was logged on content no branch commit holds)');
expect(r.status).toBe(3);
});
});
@@ -0,0 +1,4 @@
{
"bytes": 120,
"why": "GST-1160: Step 19 Approvals note that a public or unknown-visibility repo renders only Approved: yes|no (record id)"
}
@@ -0,0 +1,4 @@
{
"bytes": 1463,
"why": "ship: CI-carry, finish/accept split and early push (GSTA-258 piece A)"
}
@@ -0,0 +1,4 @@
{
"bytes": 14,
"why": "GST-1138: ship names the push guards in Step 17 and the early push"
}
+222
View File
@@ -0,0 +1,222 @@
/**
* skill-contract-stop-hook (GST-1088, lane A): blocks once when a contract
* skill ends without its outputs, fails open on every state error, and never
* blocks an interactive session that pauses on a question. Fixture skills
* carry the contract blocks; the real templates are lane B's.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { STATE_ERROR_DOC, stateErrorLine } from '../lib/skill-contract';
import {
CONTRACT_HOOK, DEBUG_REPORT, INTERACTIVE_TURNS, J1_TRANSCRIPT, ROOT, hookEnv, result, runHook, say, skillsDir, slash, tmpDir, tool, user,
} from './helpers/skill-contract-fixture';
const PINNED = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'skill-contract', 'block-message.txt'), 'utf8').trimEnd();
const STATE_ERROR = /^gstack-skill-contract: SKILL_CONTRACT_STATE_ERROR .+: .+; stop allowed, miss not recorded \(see docs\/skill-routing-and-contracts\.md#state-error\)\n$/;
test('the state error line keeps its doc anchor (d9)', () => {
expect(STATE_ERROR_DOC).toBe('docs/skill-routing-and-contracts.md#state-error');
expect(`${stateErrorLine('/x/y.json', 'corrupt record (not JSON)')}\n`).toMatch(STATE_ERROR);
});
let state = '', skills = '', work = '';
beforeEach(() => {
state = tmpDir('gstack-contract-state-');
work = tmpDir('gstack-contract-work-');
skills = skillsDir();
});
afterEach(() => {
for (const d of [state, work, skills]) fs.rmSync(d, { recursive: true, force: true });
});
const transcript = (lines: string[], name = 'transcript.jsonl') => {
const file = path.join(work, name);
fs.writeFileSync(file, `${lines.join('\n')}\n`);
return file;
};
const payload = (file: string, extra: Record<string, unknown> = {}) =>
JSON.stringify({ session_id: 'sess-1', transcript_path: file, cwd: work, hook_event_name: 'Stop', stop_hook_active: false, ...extra });
const stop = (file: string, env: Record<string, string> = {}, extra: Record<string, unknown> = {}) =>
runHook(CONTRACT_HOOK, payload(file, extra), hookEnv(state, skills, { GSTACK_SKILL_CONTRACTS: 'on', ...env }));
const record = () => JSON.parse(fs.readFileSync(path.join(state, 'skill-contracts', 'sess-1.json'), 'utf8'));
describe('block once, with the pinned text', () => {
test('J1: /investigate loaded, edited, answered with no status line -> blocked with the pinned message', () => {
const r = stop(transcript(J1_TRANSCRIPT));
expect(r.code).toBe(0);
expect(r.stderr).toBe('');
const out = JSON.parse(r.stdout);
expect(Object.keys(out).sort()).toEqual(['decision', 'reason']);
expect(out.decision).toBe('block');
expect(out.reason).toBe(PINNED);
expect(out.reason).not.toMatch(/skill_contracts|gstack-config|\boff\b/);
const run = Object.values(record().runs)[0] as any;
expect(run).toMatchObject({ skill: 'investigate', outcome: 'blocked', missing: ['debug-report', 'repro-after-fix'] });
expect(fs.statSync(path.join(state, 'skill-contracts', 'sess-1.json')).mode & 0o777).toBe(0o600);
});
test('stop_hook_active with no block of ours this run: allowed, no record written', () => {
const r = stop(transcript(J1_TRANSCRIPT), {}, { stop_hook_active: true });
expect(r).toEqual({ code: 0, stdout: '', stderr: '' });
expect(fs.existsSync(path.join(state, 'skill-contracts'))).toBe(false);
});
test('a second unmet stop in the same run records a miss and allows (blocks once)', () => {
const file = transcript(J1_TRANSCRIPT);
expect(JSON.parse(stop(file).stdout).decision).toBe('block');
fs.appendFileSync(file, `${user('thanks')}\n${say('You are welcome.')}\n`);
const again = stop(file);
expect(again).toEqual({ code: 0, stdout: '', stderr: '' });
expect((Object.values(record().runs)[0] as any).outcome).toBe('miss');
});
test('fixed after the block: met_after_block, recorded on the stop_hook_active Stop Claude Code sends next', () => {
const file = transcript(J1_TRANSCRIPT);
stop(file);
fs.appendFileSync(file, [tool('b9', 'Bash', { command: 'bun test test/auth.test.ts' }), result('b9', 'pass'), say(DEBUG_REPORT)].join('\n') + '\n');
expect(stop(file, {}, { stop_hook_active: true }).stdout).toBe('');
expect((Object.values(record().runs)[0] as any).outcome).toBe('met_after_block');
fs.appendFileSync(file, [user('also fix the README typo'), tool('e8', 'Edit', { file_path: 'README.md', old_string: 'teh', new_string: 'the' }), result('e8', 'updated'), say('Fixed.')].join('\n') + '\n');
expect(stop(file)).toEqual({ code: 0, stdout: '', stderr: '' });
expect((Object.values(record().runs)[0] as any).outcome).toBe('met_after_block');
});
test('still unmet on the stop_hook_active Stop after our block: a miss, never a second block', () => {
const file = transcript(J1_TRANSCRIPT);
stop(file);
fs.appendFileSync(file, `${say('Done.')}\n`);
expect(stop(file, {}, { stop_hook_active: true })).toEqual({ code: 0, stdout: '', stderr: '' });
expect((Object.values(record().runs)[0] as any).outcome).toBe('miss');
});
test('last_assistant_message supplies the final report the transcript file does not hold yet', () => {
const file = transcript([...J1_TRANSCRIPT.slice(0, -1), tool('b9', 'Bash', { command: 'bun test' }), result('b9', 'pass')]);
expect(stop(file, {}, { last_assistant_message: DEBUG_REPORT }).stdout).toBe('');
expect((Object.values(record().runs)[0] as any).outcome).toBe('met');
});
test('an /investigate run that ends with Status: BLOCKED in its DEBUG REPORT is met, not blocked', () => {
const file = transcript([...J1_TRANSCRIPT.slice(0, -1), say(DEBUG_REPORT.replace('Status: DONE', 'Status: BLOCKED'))]);
expect(stop(file)).toEqual({ code: 0, stdout: '', stderr: '' });
expect((Object.values(record().runs)[0] as any).outcome).toBe('met');
});
test('a met run stays met: a later unrelated edit turn in the same session is not blocked', () => {
const file = transcript([...J1_TRANSCRIPT.slice(0, -1), tool('b9', 'Bash', { command: 'bun test' }), result('b9', 'pass'), say(DEBUG_REPORT)]);
expect(stop(file).stdout).toBe('');
fs.appendFileSync(file, [user('fix the typo in README'), tool('e7', 'Edit', { file_path: 'README.md', old_string: 'teh', new_string: 'the' }), result('e7', 'updated'), say('Fixed the typo.')].join('\n') + '\n');
expect(stop(file)).toEqual({ code: 0, stdout: '', stderr: '' });
expect((Object.values(record().runs)[0] as any).outcome).toBe('met');
});
test('a waiver read from the transcript satisfies the block but counts against follow', () => {
const lines = [...J1_TRANSCRIPT.slice(0, -1),
tool('w1', 'Bash', { command: 'gstack-skill-contract waive repro-after-fix --reason "no test runner in this repo"' }),
result('w1', 'SKILL_CONTRACT_WAIVED repro-after-fix: no test runner in this repo'),
say(DEBUG_REPORT)];
expect(stop(transcript(lines)).stdout).toBe('');
expect((Object.values(record().runs)[0] as any)).toMatchObject({ outcome: 'met_waived', waived: ['repro-after-fix'] });
});
});
describe('never blocks the wrong turn', () => {
test('a multi-turn interactive session that pauses on questions is never blocked', () => {
const file = path.join(work, 'interactive.jsonl');
for (const turn of INTERACTIVE_TURNS) {
fs.appendFileSync(file, `${turn.join('\n')}\n`);
expect(stop(file)).toEqual({ code: 0, stdout: '', stderr: '' });
}
expect((Object.values(record().runs)[0] as any).outcome).toBe('met');
});
test('the same mid-task question in a headless session is checked, and blocks', () => {
const file = transcript([...INTERACTIVE_TURNS[0], ...INTERACTIVE_TURNS[1]]);
expect(JSON.parse(stop(file, { GSTACK_HEADLESS: '1' }).stdout).decision).toBe('block');
});
test('Agent readers still running when Stop fires: allowed, nothing recorded', () => {
const base = [slash('review'), say('Dispatching 2 specialists: Testing, Security. Skipped: Performance (scope not detected).')];
const background = [...base, tool('a1', 'Agent', { description: 'Testing specialist', prompt: 'review', run_in_background: true }),
result('a1', 'Async agent launched in the background.'), say('Waiting for the specialists to finish.')];
const unanswered = [...base, tool('a2', 'Agent', { description: 'Security specialist', prompt: 'review' })];
for (const lines of [background, unanswered]) {
expect(stop(transcript(lines))).toEqual({ code: 0, stdout: '', stderr: '' });
}
expect(fs.existsSync(path.join(state, 'skill-contracts'))).toBe(false);
expect(fs.readFileSync(path.join(state, 'hooks', 'skill-contract.log'), 'utf8')).toContain('event=agents-running');
});
test('no contract skill loaded: allowed', () => {
const r = stop(transcript([slash('qa'), say('Done. STATUS: DONE')]));
expect(r).toEqual({ code: 0, stdout: '', stderr: '' });
});
});
describe('fails open on state errors with one stderr line', () => {
test('unwritable state root', () => {
const blocker = path.join(work, 'state-is-a-file');
fs.writeFileSync(blocker, 'x');
const r = runHook(CONTRACT_HOOK, payload(transcript(J1_TRANSCRIPT)), hookEnv(blocker, skills, { GSTACK_SKILL_CONTRACTS: 'on' }));
expect(r.code).toBe(0);
expect(r.stdout).toBe('');
expect(r.stderr).toMatch(STATE_ERROR);
expect(r.stderr).toContain('cannot write record');
});
test('corrupt record', () => {
fs.mkdirSync(path.join(state, 'skill-contracts'), { recursive: true });
fs.writeFileSync(path.join(state, 'skill-contracts', 'sess-1.json'), '{not json');
const r = stop(transcript(J1_TRANSCRIPT));
expect(r.code).toBe(0);
expect(r.stdout).toBe('');
expect(r.stderr).toMatch(STATE_ERROR);
expect(r.stderr).toContain('corrupt record');
});
test('missing transcript, bad stdin and an invalid contract', () => {
const missing = stop(path.join(work, 'nope.jsonl'));
expect(missing.stdout).toBe('');
expect(missing.stderr).toMatch(STATE_ERROR);
const bad = runHook(CONTRACT_HOOK, '{oops', hookEnv(state, skills, { GSTACK_SKILL_CONTRACTS: 'on' }));
expect(bad.code).toBe(0);
expect(bad.stderr).toMatch(STATE_ERROR);
const invalid = skillsDir([], { investigate: '---\nname: investigate\ncontract:\n version: 2\n items: []\n---\n' });
const r = runHook(CONTRACT_HOOK, payload(transcript(J1_TRANSCRIPT)), hookEnv(state, invalid, { GSTACK_SKILL_CONTRACTS: 'on' }));
fs.rmSync(invalid, { recursive: true, force: true });
expect(r.stdout).toBe('');
expect(r.stderr).toMatch(STATE_ERROR);
expect(r.stderr).toContain('contract version 2 is not supported');
});
test('unknown transcript shape fails open and bumps the canary', () => {
const file = transcript(['{"kind":"something-else"}', '{"kind":"still-not-a-transcript"}']);
const r = stop(file);
expect(r.stdout).toBe('');
expect(r.stderr).toMatch(STATE_ERROR);
expect(r.stderr).toContain('unknown transcript shape');
stop(file);
expect(JSON.parse(fs.readFileSync(path.join(state, 'hooks', 'skill-contract.canary.json'), 'utf8')).count).toBe(2);
});
});
describe('off switch', () => {
test('default off, config off, and env off over config on: no output at all', () => {
const file = transcript(J1_TRANSCRIPT);
expect(runHook(CONTRACT_HOOK, payload(file), hookEnv(state, skills))).toEqual({ code: 0, stdout: '', stderr: '' });
fs.writeFileSync(path.join(state, 'config.yaml'), 'skill_contracts: off\n');
expect(runHook(CONTRACT_HOOK, payload(file), hookEnv(state, skills))).toEqual({ code: 0, stdout: '', stderr: '' });
fs.writeFileSync(path.join(state, 'config.yaml'), 'skill_contracts: on\n');
expect(runHook(CONTRACT_HOOK, payload(file), hookEnv(state, skills, { GSTACK_SKILL_CONTRACTS: 'off' }))).toEqual({ code: 0, stdout: '', stderr: '' });
expect(JSON.parse(runHook(CONTRACT_HOOK, payload(file), hookEnv(state, skills)).stdout).decision).toBe('block');
});
test('the bash shim exits 0 and passes the block through', () => {
const { spawnSync } = require('node:child_process') as typeof import('node:child_process');
const r = spawnSync('bash', [path.join(ROOT, 'hosts', 'claude', 'hooks', 'skill-contract-stop-hook')], {
input: payload(transcript(J1_TRANSCRIPT)), env: hookEnv(state, skills, { GSTACK_SKILL_CONTRACTS: 'on' }), encoding: 'utf8', timeout: 20_000,
});
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).reason).toBe(PINNED);
});
});
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more