mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 03:00:26 +01:00
v1.92.62.0 stamp: merge main dd9834d44d into #3832 (land)
This commit is contained in:
commit
ef17b85a81
103 files changed
+4033
-455
No files matched your search
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* GST-537 PR 2: `gstack-autoplan next --ceo-only`, the driver twin of
|
||||
* `/autoplan --ceo-only`. The run opens only the CEO phase, ends
|
||||
* status=reviewed with ceo-review.md and auto_kept decision rows, never writes
|
||||
* a gate or approves, keeps its artifacts schema-valid, and the verbs that need
|
||||
* a gate refuse it with a coded line. The flag is command-line only.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { importAutoplan } from '../lib/approval-record';
|
||||
import { ROOT, SMALL_PLAN, attemptsOf, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-ceo-only-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
const dir = (name: string) => path.join(TMP, name);
|
||||
const rows = (file: string) => fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
|
||||
describe('next --ceo-only runs the CEO phase alone and ends reviewed', () => {
|
||||
const out = startSmallRun(dir('reviewed'), ['--ceo-only']);
|
||||
const phase = runPhase(out, 'ceo');
|
||||
const run = readRun(out);
|
||||
|
||||
test('init records the mode and skips every other phase', () => {
|
||||
expect(run.ceo_only).toBe(true);
|
||||
expect(run.phase_order).toEqual(['ceo']);
|
||||
expect(run.required_phases).toEqual(['ceo']);
|
||||
for (const p of ['design', 'eng', 'dx']) expect(run.phases[p].skipped).toBe('ceo-only run');
|
||||
expect(journal(out).find(r => r.event === 'init').ceo_only).toBe(true);
|
||||
});
|
||||
|
||||
test('closing CEO ends status=reviewed with the report, and no gate', () => {
|
||||
expect(phase.outside.status).toBe(0);
|
||||
expect(phase.outside.stdout).toContain('REVIEWED: ceo-only');
|
||||
expect(phase.outside.stdout.trimEnd().split('\n').at(-1)).toBe(`GSTACK_RESULT: skill=autoplan status=reviewed run=${out}`);
|
||||
expect(phase.outside.stdout).not.toContain('GATE:');
|
||||
expect(run.status).toBe('reviewed');
|
||||
expect(run.gate_rev).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(out, 'gate.json'))).toBe(false);
|
||||
expect(journal(out).some(r => r.event === 'gate_written')).toBe(false);
|
||||
});
|
||||
|
||||
test('ceo-review.md has the shared report sections and is hashed in run.json', () => {
|
||||
const report = fs.readFileSync(path.join(out, 'ceo-review.md'), 'utf8');
|
||||
expect(report.split('\n')[0]).toBe('# CEO-only review: Retry backoff for the webhook worker');
|
||||
expect(report).toContain('Not approved.');
|
||||
for (const h of ['## User challenges (direction kept)', '## Scope added', '## Conservative choices', '## Findings', '## Decision Audit Trail']) expect(report).toContain(h);
|
||||
expect(report).toContain('Docs link the quickstart');
|
||||
const sha = crypto.createHash('sha256').update(fs.readFileSync(path.join(out, 'ceo-review.md'))).digest('hex');
|
||||
expect(run.artifacts['ceo-review.md']).toEqual({ path: 'ceo-review.md', sha256: sha });
|
||||
});
|
||||
|
||||
test('decision rows are auto, auto_kept, the direction kept and unanswered; tasks name no p1', () => {
|
||||
const decisions = rows(path.join(out, 'decisions.jsonl'));
|
||||
expect(decisions.length).toBeGreaterThan(0);
|
||||
for (const d of decisions) {
|
||||
expect(d.kind).toBe('auto');
|
||||
expect(d.status).toBe('auto_kept');
|
||||
expect(d.chosen).toBe('a');
|
||||
expect(d.answered_at).toBeUndefined();
|
||||
expect(d.id.endsWith('-p1')).toBe(false);
|
||||
}
|
||||
const tasks = rows(path.join(out, 'tasks.jsonl'));
|
||||
expect(tasks.length).toBeGreaterThan(0);
|
||||
for (const t of tasks) { expect(t.blocked_by).toEqual([]); expect(t.status).not.toBe('blocked_on_decision'); }
|
||||
});
|
||||
|
||||
test('an open Critical or High finding is decided by nobody: no decision row, still in the report and the tasks for the owner', () => {
|
||||
const decisions = rows(path.join(out, 'decisions.jsonl'));
|
||||
expect(decisions.some(d => /^(Critical|High): /.test(d.title))).toBe(false);
|
||||
const report = fs.readFileSync(path.join(out, 'ceo-review.md'), 'utf8');
|
||||
expect(report).toMatch(/\[Critical\] The install check lies by omission .*disposition: /);
|
||||
expect(report).toMatch(/\[High\] Gate reuse on tree equality alone is unsound .*disposition: /);
|
||||
expect(report).not.toMatch(/(Critical|High): .*kept/);
|
||||
const tasks = rows(path.join(out, 'tasks.jsonl'));
|
||||
expect(tasks.some(t => t.title === 'Gate reuse on tree equality alone is unsound' && t.priority === 'High')).toBe(true);
|
||||
});
|
||||
|
||||
test('the artifact set validates and the plan is untouched', () => {
|
||||
const v = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 60_000 });
|
||||
expect(v.stdout).toContain('ARTIFACT_VALID: ');
|
||||
expect(v.stdout).toContain('status=reviewed');
|
||||
expect(v.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(dir('reviewed'), 'plan.md'), 'utf8')).toBe(SMALL_PLAN);
|
||||
});
|
||||
|
||||
test('a later next or resume keeps reviewed and writes no gate', () => {
|
||||
const next = autoplan(['next', '--out', out]);
|
||||
expect(next.status).toBe(0);
|
||||
expect(next.stdout).toContain('status=reviewed');
|
||||
const resume = autoplan(['resume', '--out', out]);
|
||||
expect(resume.status).toBe(0);
|
||||
expect(resume.stdout).toContain('status=reviewed');
|
||||
expect(fs.existsSync(path.join(out, 'gate.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('answer, recommend and revise refuse CEO_ONLY_NO_GATE', () => {
|
||||
const plan = path.join(dir('reviewed'), 'plan.md');
|
||||
for (const args of [['answer', '--gate-rev', '1', '--reply', 'rec', '--by', 'human'], ['answer', '--reply', 'rec', '--by', 'human'],
|
||||
['recommend', '--gate-rev', '1', '--item', 'd1', '--option', 'b', '--basis', 'x'], ['revise', '--plan', plan]]) {
|
||||
const r = autoplan([args[0]!, '--out', out, ...args.slice(1)]);
|
||||
expect(r.status, args.join(' ')).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_NO_GATE)');
|
||||
}
|
||||
expect(readRun(out).status).toBe('reviewed');
|
||||
});
|
||||
|
||||
test('approvals import refuses a CEO-only run and an unreadable one', () => {
|
||||
expect(importAutoplan(out, 'main')).toMatchObject({ code: 'CEO_ONLY_NO_GATE' });
|
||||
const bad = dir('bad-run');
|
||||
fs.mkdirSync(bad, { recursive: true });
|
||||
fs.writeFileSync(path.join(bad, 'decisions.jsonl'), '');
|
||||
fs.writeFileSync(path.join(bad, 'run.json'), '{not json');
|
||||
expect(importAutoplan(bad, 'main')).toMatchObject({ code: 'ARTIFACT_INVALID_JSON' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a budget-stopped CEO-only run recovers the way a full run does', () => {
|
||||
const stopped = (name: string, extra: Record<string, string> = {}) => {
|
||||
fs.mkdirSync(dir(name), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir(name), 'plan.md'), SMALL_PLAN);
|
||||
const out = path.join(dir(name), 'run');
|
||||
const first = autoplan(['next', '--out', out, '--plan', path.join(dir(name), 'plan.md'), '--no-ui', '--ceo-only', '--spend-cap', '1', '--estimate-usd', '5'], { env: env(extra) });
|
||||
expect(first.status).toBe(3);
|
||||
expect(first.stderr).toContain('(SPEND_CAP_EXCEEDED)');
|
||||
expect(readRun(out).status).toBe('incomplete');
|
||||
return out;
|
||||
};
|
||||
|
||||
test('budget then resume lifts the stop, and the run ends reviewed', () => {
|
||||
const out = stopped('stopped-budget');
|
||||
const budget = autoplan(['budget', '--out', out, '--spend-cap', '50', '--by', 'human']);
|
||||
expect(budget.status, budget.stderr).toBe(0);
|
||||
expect(autoplan(['resume', '--out', out]).stdout).toContain('STOP_LIFTED');
|
||||
runPhase(out, 'ceo');
|
||||
expect(readRun(out).status).toBe('reviewed');
|
||||
expect(fs.existsSync(path.join(out, 'ceo-review.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('close --deferred finishes it reviewed, with the report and no gate', () => {
|
||||
const out = stopped('stopped-close');
|
||||
const close = autoplan(['close', '--out', out, '--phase', 'ceo', '--deferred', 'out of budget', '--by', 'human']);
|
||||
expect(close.status, close.stderr).toBe(0);
|
||||
expect(close.stdout).toContain('REVIEWED: ceo-only');
|
||||
expect(close.stdout.trimEnd().split('\n').at(-1)).toBe(`GSTACK_RESULT: skill=autoplan status=reviewed run=${out}`);
|
||||
expect(fs.existsSync(path.join(out, 'ceo-review.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(out, 'gate.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewed is a status at rest for checkpoints', () => {
|
||||
test('resume on a reviewed run publishes a reviewed generation', () => {
|
||||
const root = path.join(dir('ckpt'), 'drive');
|
||||
const out = startSmallRun(dir('ckpt'), ['--ceo-only']);
|
||||
runPhase(out, 'ceo');
|
||||
expect(readRun(out).status).toBe('reviewed');
|
||||
const resumed = autoplan(['resume', '--out', out], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root }) });
|
||||
expect(resumed.stderr).toContain('status=reviewed');
|
||||
const current = JSON.parse(fs.readFileSync(path.join(root, readRun(out).run, 'current.json'), 'utf8'));
|
||||
expect(current.status).toBe('reviewed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--ceo-only is a command-line flag for one run', () => {
|
||||
const plan = (name: string, text = SMALL_PLAN) => { fs.mkdirSync(dir(name), { recursive: true }); const f = path.join(dir(name), 'plan.md'); fs.writeFileSync(f, text); return f; };
|
||||
|
||||
test('a phase-adding flag beside it refuses before anything is written', () => {
|
||||
for (const flag of ['--ui', '--developer-tool', '--agent-primary']) {
|
||||
const out = path.join(dir(`conflict${flag}`), 'run');
|
||||
const r = autoplan(['next', '--out', out, '--plan', plan(`conflict${flag}`), '--ceo-only', flag]);
|
||||
expect(r.status, flag).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('a scope marker naming it refuses, with or without the flag', () => {
|
||||
const marked = plan('marker', `<!-- gstack-autoplan-scope: --no-ui --ceo-only -->\n${SMALL_PLAN}`);
|
||||
for (const extra of [[], ['--ceo-only']]) {
|
||||
const r = autoplan(['next', '--out', path.join(dir('marker'), `run${extra.length}`), '--plan', marked, ...extra]);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
}
|
||||
});
|
||||
|
||||
test('a scope marker that adds a phase conflicts with the flag', () => {
|
||||
for (const token of ['--ui', '--developer-tool', '--agent-primary']) {
|
||||
const marked = plan(`marker${token}`, `<!-- gstack-autoplan-scope: ${token} -->
|
||||
${SMALL_PLAN}`);
|
||||
const out = path.join(dir(`marker${token}`), 'run');
|
||||
const r = autoplan(['next', '--out', out, '--plan', marked, '--ceo-only']);
|
||||
expect(r.status, token).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('a directory that holds a full run refuses it after init', () => {
|
||||
const out = startSmallRun(dir('full'));
|
||||
const r = autoplan(['next', '--out', out, '--ceo-only']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_AFTER_INIT)');
|
||||
expect(readRun(out).ceo_only).toBeUndefined();
|
||||
expect(readRun(out).phase_order).toContain('eng');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the CEO-only finish records no answer nobody gave and writes only its own report', () => {
|
||||
test('a Critical left open by close --deferred stays undecided: no chosen option, listed as needing the owner', () => {
|
||||
const out = startSmallRun(dir('blocker'), ['--ceo-only']);
|
||||
const r = review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Critical', title: 'Retries can double-charge', fix: 'idempotency key' }]) });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const ids = attemptsOf(out, 'ceo');
|
||||
const bound = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5', '--attempt', ids.native, '--runner', 'host-subagent']);
|
||||
expect(bound.status, bound.stderr).toBe(0);
|
||||
const close = autoplan(['close', '--out', out, '--phase', 'ceo', '--deferred', 'owner stop', '--by', 'human']);
|
||||
expect(close.status, close.stderr).toBe(0);
|
||||
expect(close.stdout).toContain('REVIEWED: ceo-only');
|
||||
const blocker = rows(path.join(out, 'decisions.jsonl')).find(d => d.title.includes('Retries can double-charge'));
|
||||
expect(blocker).toMatchObject({ kind: 'auto', status: 'auto_kept', chosen: null, default_if_unanswered: null });
|
||||
const report = fs.readFileSync(path.join(out, 'ceo-review.md'), 'utf8');
|
||||
expect(report).not.toContain('you accept the risk');
|
||||
expect(report).toMatch(/Retries can double-charge.*left open for the owner/);
|
||||
});
|
||||
|
||||
test('a plan at the report path refuses before anything is written', () => {
|
||||
const out = path.join(dir('collide'), 'run');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const plan = path.join(out, 'ceo-review.md');
|
||||
fs.writeFileSync(plan, SMALL_PLAN);
|
||||
const r = autoplan(['next', '--out', out, '--plan', plan, '--no-ui', '--ceo-only']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
|
||||
expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
|
||||
});
|
||||
|
||||
test('a plan that reaches the report path through a symlink, or through a symlinked --out, refuses too', () => {
|
||||
const real = path.join(dir('alias'), 'real-run');
|
||||
fs.mkdirSync(real, { recursive: true });
|
||||
const report = path.join(real, 'ceo-review.md');
|
||||
fs.writeFileSync(report, SMALL_PLAN);
|
||||
const link = path.join(dir('alias'), 'input.md');
|
||||
fs.symlinkSync(report, link);
|
||||
const linkedOut = path.join(dir('alias'), 'linked-run');
|
||||
fs.symlinkSync(real, linkedOut);
|
||||
for (const [plan, out] of [[link, real], [report, linkedOut]]) {
|
||||
const r = autoplan(['next', '--out', out!, '--plan', plan!, '--no-ui', '--ceo-only']);
|
||||
expect(r.status, plan).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
expect(fs.existsSync(path.join(real, 'run.json'))).toBe(false);
|
||||
}
|
||||
expect(fs.readFileSync(report, 'utf8')).toBe(SMALL_PLAN);
|
||||
});
|
||||
|
||||
test('a run whose plan entry is the report file refuses at the finish and leaves it intact', () => {
|
||||
const out = startSmallRun(dir('same-entry'), ['--ceo-only']);
|
||||
const report = path.join(out, 'ceo-review.md');
|
||||
fs.writeFileSync(report, SMALL_PLAN);
|
||||
const run = readRun(out);
|
||||
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify({ ...run, plan_input: report }, null, 2) + '\n');
|
||||
const submit = runPhase(out, 'ceo').outside;
|
||||
expect(submit.status).toBe(3);
|
||||
expect(submit.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
expect(fs.readFileSync(report, 'utf8')).toBe(SMALL_PLAN);
|
||||
expect(readRun(out).status).not.toBe('reviewed');
|
||||
});
|
||||
|
||||
test('a plan hard-linked to the report path finishes: the rename replaces the link, never the plan', () => {
|
||||
const out = startSmallRun(dir('hardlink'), ['--ceo-only']);
|
||||
const plan = path.join(dir('hardlink'), 'plan.md');
|
||||
fs.linkSync(plan, path.join(out, 'ceo-review.md'));
|
||||
runPhase(out, 'ceo');
|
||||
expect(readRun(out).status).toBe('reviewed');
|
||||
expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
|
||||
});
|
||||
|
||||
test('a --plan given as a symlink at the report path refuses', () => {
|
||||
const out = path.join(dir('report-link'), 'run');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const plan = path.join(dir('report-link'), 'plan.md');
|
||||
fs.writeFileSync(plan, SMALL_PLAN);
|
||||
fs.symlinkSync(plan, path.join(out, 'ceo-review.md'));
|
||||
const r = autoplan(['next', '--out', out, '--plan', path.join(out, 'ceo-review.md'), '--no-ui', '--ceo-only']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
});
|
||||
|
||||
test('a symlink at the report path is replaced, never written through', () => {
|
||||
const out = startSmallRun(dir('symlink'), ['--ceo-only']);
|
||||
const victim = path.join(dir('symlink'), 'victim.txt');
|
||||
fs.writeFileSync(victim, 'keep me');
|
||||
fs.symlinkSync(victim, path.join(out, 'ceo-review.md'));
|
||||
runPhase(out, 'ceo');
|
||||
expect(readRun(out).status).toBe('reviewed');
|
||||
expect(fs.readFileSync(victim, 'utf8')).toBe('keep me');
|
||||
expect(fs.lstatSync(path.join(out, 'ceo-review.md')).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,24 @@ describe('factory-scorecard: recorded window', () => {
|
||||
expect(values.releases).toBe('1 (v1.0.0)');
|
||||
});
|
||||
|
||||
test('free runs that ran no shards (reused main pushes) stay out of the free-retry count and its denominator (#3725)', () => {
|
||||
// Value: protects=the free-retry rate is read over runs that executed shards only; fails_when=a reused run (no artifacts, reuse job green) counts as a zero-retry run again; why_new=#3725, after #3709 a main push that reuses its PR's run uploads nothing; seam=fixtureSource; no_claim=runs past --max-artifact-runs, which are not read
|
||||
const fx = fixture();
|
||||
const freeRuns = fx.responses[Object.keys(fx.responses).find(k => k.includes('/free-tests.yml/runs?'))!] as { workflow_runs: any[] };
|
||||
const real = freeRuns.workflow_runs[0];
|
||||
freeRuns.workflow_runs.splice(0, 0, { ...real, id: 8005, created_at: '2026-10-25T18:00:00Z' });
|
||||
freeRuns.workflow_runs.splice(2, 0, { ...real, id: 8004, created_at: '2026-10-24T18:00:00Z' });
|
||||
for (const id of [8004, 8005]) fx.responses[`repos/acme/widgets/actions/runs/${id}/attempts/1/jobs?per_page=100&page=1`] = { jobs: [
|
||||
{ id: 1, name: 'reuse', conclusion: 'success' }, { id: 2, name: 'free-plan', conclusion: 'skipped' }, { id: 3, name: 'free-tests', conclusion: 'success' }] };
|
||||
const card = collectScorecard(fixtureSource(fx, scratch()), options());
|
||||
const clean = collectScorecard(fixtureSource(fixture(), scratch()), options());
|
||||
expect(card.unavailable).toEqual([]);
|
||||
expect(card.freeRuns).toEqual(clean.freeRuns);
|
||||
expect(card.freeRuns.map(r => [r.id, r.retries])).toEqual([[8003, 0], [8002, 0], [8001, 2]]);
|
||||
expect([card.freeRunsSkipped, clean.freeRunsSkipped]).toEqual([2, 0]);
|
||||
expect(byId(scorecardRows(card)).flaky).toBe('2 paid case(s) passed on their one rerun (rerun spend $1.10); 2 free-suite retries in 1 of 3 runs; 2 reused main push(es) left out');
|
||||
});
|
||||
|
||||
test('a crew login list overrides the author_association rule', () => {
|
||||
const card = collectScorecard(fixtureSource(fixture(), scratch()), options({ crew: ['Alice'] }));
|
||||
expect(byId(scorecardRows(card)).merged).toBe('3 (crew 1, outside 2)');
|
||||
|
||||
@@ -299,6 +299,12 @@
|
||||
}
|
||||
},
|
||||
"artifacts": {
|
||||
"8003": {
|
||||
"free-result-shard-1": { "result.json": "{}" }
|
||||
},
|
||||
"8002": {
|
||||
"free-result-shard-1": { "result.json": "{}" }
|
||||
},
|
||||
"8001": {
|
||||
"flake-ledger-shard-1": {
|
||||
"flake-ledger.jsonl": "{\"kind\":\"flaky-pass\",\"file\":\"test/a.test.ts\",\"shard\":\"shard-1\",\"attempts\":2}\n{\"kind\":\"flaky-pass\",\"file\":\"test/b.test.ts\",\"shard\":\"shard-1\",\"attempts\":2}\n{\"kind\":\"other\",\"file\":\"test/c.test.ts\"}\n"
|
||||
|
||||
+4
-3
@@ -17,9 +17,10 @@ This skill follows gstack shared preamble `6872f1ec4eb9` (AskUserQuestion format
|
||||
## Preamble (run first)
|
||||
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=~/.gemini/antigravity-cli/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host agy from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
GSTACK_BIN=$GSTACK_ROOT/bin
|
||||
"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "gemini"
|
||||
[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack
|
||||
[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=~/.gemini/antigravity-cli/skills/gstack
|
||||
[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host agy from your gstack checkout; ./setup --status shows it.">&2
|
||||
"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "gemini"
|
||||
```
|
||||
|
||||
**Antigravity tool names:** `AskUserQuestion` means your `ask_question` tool (options as choices); there is no `mcp__*__AskUserQuestion` variant. The Bash tool means `run_command`, the Read tool `view_file`, the Write tool `write_to_file`, the Edit tool `replace_file_content` (`multi_replace_file_content` for several edits in one file), Grep `grep_search`, Glob `find_by_name`, and the Agent tool `invoke_subagent`. There is no Skill tool: load a skill by reading its SKILL.md with `view_file`. If a step says to call `ExitPlanMode`, tell the user the plan is ready and wait. `view_file` can return only part of a long file: when a SKILL.md or a file it tells you to read ends before its last line, read the remaining line ranges before acting on it.
|
||||
|
||||
+4
-3
@@ -17,9 +17,10 @@ This skill follows gstack shared preamble `2af24c2d75e4` (AskUserQuestion format
|
||||
## Preamble (run first)
|
||||
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
GSTACK_BIN=$GSTACK_ROOT/bin
|
||||
"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "gpt"
|
||||
[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack
|
||||
[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${CODEX_HOME:-~/.codex}/skills/gstack
|
||||
[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2
|
||||
"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "gpt"
|
||||
```
|
||||
|
||||
Read the echoed `KEY: value` STATUS lines — they drive every preamble rule
|
||||
|
||||
+4
-3
@@ -19,9 +19,10 @@ This skill follows gstack shared preamble `99cd6cb5d373` (AskUserQuestion format
|
||||
## Preamble (run first)
|
||||
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
GSTACK_BIN=$GSTACK_ROOT/bin
|
||||
"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "claude"
|
||||
[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack
|
||||
[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=~/.factory/skills/gstack
|
||||
[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2
|
||||
"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "claude"
|
||||
```
|
||||
|
||||
Read the echoed `KEY: value` STATUS lines — they drive every preamble rule
|
||||
|
||||
+4
@@ -59,6 +59,7 @@
|
||||
{ "file": "scripts/eval-bg.ts", "movedFrom": "(new: eval:bg dispatch/local backend, W5d)" },
|
||||
{ "file": "test/helpers/module-size.ts", "movedFrom": "(new: ratchet (c) counter)" },
|
||||
{ "file": "scripts/lib/free-home-guard.ts", "movedFrom": "(new: free-lane home-write tripwire, kept out of scripts/test-free-shards.ts)" },
|
||||
{ "file": "scripts/lib/shard-jobs.ts", "movedFrom": "(new: --ci-run --jobs sub-shards and per-file JUnit counts, kept out of scripts/test-free-shards.ts and scripts/lib/shard-engine.ts, GSTA-203)" },
|
||||
{ "file": "browse/src/routes/activity.ts", "movedFrom": "browse/src/server.ts" },
|
||||
{ "file": "browse/src/routes/commands.ts", "movedFrom": "browse/src/server.ts" },
|
||||
{ "file": "browse/src/routes/core.ts", "movedFrom": "browse/src/server.ts" },
|
||||
@@ -105,6 +106,7 @@
|
||||
{ "file": "lib/result-codes-budget.ts", "movedFrom": "lib/result-codes.ts (the /ship token budget and handoff rows; GSTA-152)" },
|
||||
{ "file": "lib/result-codes-eval.ts", "movedFrom": "lib/result-codes.ts (the /eval-plan preregistration rows and the no-gstack arm; GSTA-168)" },
|
||||
{ "file": "lib/result-codes-plan.ts", "movedFrom": "lib/result-codes.ts (the plan-review fact-check and task-list rows: REALITY receipts, the office-hours probe, gstack-tasks; GSTA-228)" },
|
||||
{ "file": "lib/result-codes-autoplan.ts", "movedFrom": "lib/result-codes.ts (the CEO-only driver run rows; GST-537)" },
|
||||
{ "file": "lib/ship-budget.ts", "movedFrom": "(new: /ship token budget ledger, meters and admission, GSTA-152)" },
|
||||
{ "file": "lib/ship-budget-cli.ts", "movedFrom": "(new: bin/gstack-ship-budget start/check/settle/close/resume, GSTA-152)" },
|
||||
{ "file": "lib/ship-handoff.ts", "movedFrom": "(new: /ship fresh-context handoff, verify, return and reconcile, GSTA-152)" },
|
||||
@@ -127,6 +129,7 @@
|
||||
{ "file": "lib/autoplan-state.ts", "movedFrom": "(new: the unattended runner's on-disk state, journal and locked writes, B2)" },
|
||||
{ "file": "lib/autoplan-run.ts", "movedFrom": "(new: next/submit/resume over lib/autoplan-state.ts, B2)" },
|
||||
{ "file": "lib/autoplan-gate.ts", "movedFrom": "(new: phase close, gate build, answer and reopen, B2/B9)" },
|
||||
{ "file": "lib/autoplan-ceo-only.ts", "movedFrom": "(new: the CEO-only driver run's finish, report and refusals, GST-537)" },
|
||||
{ "file": "lib/autoplan-export.ts", "movedFrom": "(new: the unattended export file set, B2/B12)" },
|
||||
{ "file": "lib/spec-review-cap.ts", "movedFrom": "(new: the spec-review loop at the cap: numbered gaps and deterministic fix verification, B8)" },
|
||||
{ "file": "bin/gstack-autoplan", "movedFrom": "(new: thin entrypoint over lib/autoplan-run.ts, B2)" },
|
||||
@@ -174,6 +177,7 @@
|
||||
{ "file": "lib/ship-queue-train-verify.ts", "movedFrom": "(new: verify --train, the queue freeze and its backstop, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/ship-queue-train-ready.ts", "movedFrom": "(new: ready --screen|--no-screen and the READY receipt, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/ship-queue-land-lock.ts", "movedFrom": "lib/ship-queue-land.ts (the land lock, shared with train land, #3599)" },
|
||||
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
|
||||
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
|
||||
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"source": "garrytan/gbrain-evals#289 (merge 513de26d): changed lines of its code-class paths, read as text via gh pr diff",
|
||||
"paths": [
|
||||
"CHANGELOG.md",
|
||||
"docs/benchmarks/cat40-hard/accepted-drift.json",
|
||||
"docs/benchmarks/cat40-hard/calibration.md",
|
||||
"docs/usage-receipt.md",
|
||||
"eval/runner/cat40/loop.ts",
|
||||
"eval/runner/harness-metering-proxy.ts",
|
||||
"eval/runner/providers/openai-compatible.ts",
|
||||
"eval/runner/q1/cell.ts",
|
||||
"eval/runner/systems/baselines.ts",
|
||||
"eval/workload-suites/bench-infra.ts",
|
||||
"scripts/particle-canary.ts",
|
||||
"test/eval/cat40-particle-loop.test.ts",
|
||||
"test/eval/file-agent.test.ts",
|
||||
"test/eval/harness-metering-proxy.test.ts",
|
||||
"test/eval/memory-qa-particle.test.ts",
|
||||
"test/eval/openai-compatible.test.ts",
|
||||
"test/eval/particle-canary.test.ts",
|
||||
"test/eval/q1-cell.test.ts",
|
||||
"test/eval/workload-particle.test.ts"
|
||||
],
|
||||
"added": {
|
||||
"eval/runner/cat40/loop.ts": [
|
||||
"/** A provider's non-2xx answer, after backoff for retryable statuses, or a particle 2xx without a message (its 2xx status). */",
|
||||
" * Particle runs only: the run ended on a turn that timed out, lost its connection after it went out, or was answered",
|
||||
" * 2xx without a message (`compatMayHaveBilled`). Particle may have billed that turn, so a caller never runs the",
|
||||
" * question again.",
|
||||
" * recorded, and a 2xx that is not JSON or has no `choices[0].message` is recorded and thrown as a ProviderError with",
|
||||
" * its 2xx status, never sent again, because the provider answered it and may have billed it (issue #280). A `compat`",
|
||||
" * request is sent with `compatDeadlineMs` instead of Bun's 300-second fetch timeout, and a 504 (a gateway's or the lease",
|
||||
" const answered = new ProviderError(res.status, `${compat} answered ${res.status} ${parsed ? 'without choices[0].message' : 'with a body that is not JSON'}: ${text.slice(0, 300)}`);",
|
||||
" onAttempt?.({ status: 'error', response: json, error: answered.message, ms: Date.now() - t, retry_after_ms: null });",
|
||||
" throw answered;"
|
||||
],
|
||||
"eval/runner/harness-metering-proxy.ts": [
|
||||
" * A particle request that failed after it went out (the upstream deadline",
|
||||
" * passed, the connection reset or closed, or the response body failed to",
|
||||
" * read) is answered 504 `mpw_proxy_upstream_lost`, which no particle client",
|
||||
" * sends again, because Particle may have billed it. Any other upstream",
|
||||
" * failure, such as a refused connection, is a 502",
|
||||
" * `mpw_proxy_upstream_unreachable` that a client may send again (issue #282).",
|
||||
" *",
|
||||
"import { carriesKeyInCleartext, COMPAT_PROVIDERS, compatConnectionLost } from './providers/openai-compatible.ts';",
|
||||
" const lost = provider === 'particle' && ((error as Error).name === 'TimeoutError' || compatConnectionLost(error));",
|
||||
" const message = `metering proxy ${lost ? 'lost the request it sent to' : 'could not reach'} ${provider}: ${(error as Error).message}`;",
|
||||
" return finish(lost ? errorResponse(504, 'mpw_proxy_upstream_lost', message) : errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, 0, message), error: message });",
|
||||
" return finish(provider === 'particle' ? errorResponse(504, 'mpw_proxy_upstream_lost', message) : errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, upstream.status, message), error: message });"
|
||||
],
|
||||
"eval/runner/providers/openai-compatible.ts": [
|
||||
"/** A non-2xx answer that is not a budget stop, or a 2xx answer without a usable `choices[0].message` (`compatSend`). */",
|
||||
" * A request the provider may have billed: it timed out (`compatTimedOut`), its connection was lost after it went out",
|
||||
" * (`compatConnectionLost`), or it was answered 2xx without a usable `choices[0].message` (an error carrying that 2xx",
|
||||
" * status, issue #280). A caller never sends it again, with or without a metering proxy in front.",
|
||||
" const status = (e as { status?: unknown } | null)?.status;",
|
||||
" return compatTimedOut(e) || compatConnectionLost(e) || (typeof status === 'number' && status >= 200 && status < 300);",
|
||||
" * decides any retry. A 2xx answer without `choices[0].message`, or whose body is not JSON, is an error carrying its 2xx",
|
||||
" * status and the usage it reported, never an empty answer, and it is not retryable: the provider answered, so it may",
|
||||
" * have billed it (`compatMayHaveBilled`, issue #280). `timeoutMs` (300 s unless given) is the only deadline: Bun's own",
|
||||
" * 300-second fetch timeout is off, so a longer deadline holds.",
|
||||
" catch { throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: false, retryAfterMs: null, body: `response body is not JSON: ${text.slice(0, 200)}` }); }",
|
||||
" if (!Array.isArray(choices) || !choices[0]?.message) throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: false, retryAfterMs: null, body: `response has no choices[0].message: ${text.slice(0, 200)}` }, parsed.usage);"
|
||||
],
|
||||
"eval/runner/q1/cell.ts": [
|
||||
" /** `final`: a try that must not be sent again (an OpenAI-compatible request that timed out, lost its connection or was answered 2xx without a message, which the provider may have billed). */"
|
||||
],
|
||||
"eval/runner/systems/baselines.ts": [
|
||||
" /** The last request timed out, lost its connection after it went out or was answered 2xx without a message; Particle may have billed it, so the cell never sends it again. */",
|
||||
" * (`compatDeadlineMs`), and one that times out, loses its connection after it went out or is answered 2xx without a",
|
||||
" * message (`compatMayHaveBilled`) is a `reader_error` that is never sent again, because Particle may have billed it. Each attempt is recorded, the failed ones with null response"
|
||||
],
|
||||
"eval/workload-suites/bench-infra.ts": [
|
||||
"import { COMPAT_PROVIDERS, CompatHttpError, ParticleCreditExhausted, compatMayHaveBilled, compatRequest, compatSend, type CompatRequest, type CompatResponse } from '../runner/providers/openai-compatible.ts';",
|
||||
" * not Bun's 300-second fetch timeout. A request Particle may have billed (`compatMayHaveBilled`: it timed out, lost its",
|
||||
" * connection after it went out, was answered 504, which the harness proxy sends when its upstream request failed after",
|
||||
" * it went out, or was answered 2xx without a message) is never sent again (issue #282).",
|
||||
" const retry = !compatMayHaveBilled(error) && (http ? http.retryable && attempt < 5 : !budget && attempt < 4);"
|
||||
],
|
||||
"scripts/particle-canary.ts": [
|
||||
"import { CompatHttpError, assertMetered, compatBaseUrl, compatMayHaveBilled, compatRequest, compatSend, isLoopbackUrl, type CanarySampling, type CompatLogprob, type CompatRequest } from '../eval/runner/providers/openai-compatible.ts';",
|
||||
"/**",
|
||||
" * Send the canary once per prompt (up to 3 attempts on a 429 or 5xx, honoring a capped Retry-After). A request Particle",
|
||||
" * may have billed (`compatMayHaveBilled`: a 504, or a 2xx without a message) is never sent again (issue #282). A budget",
|
||||
" * stop ends it after its receipt.",
|
||||
" */",
|
||||
" if (!(e instanceof CompatHttpError && e.retryable) || compatMayHaveBilled(e) || attempt === ATTEMPTS - 1) break;"
|
||||
]
|
||||
},
|
||||
"removed": {
|
||||
"eval/runner/cat40/loop.ts": [
|
||||
"/** A provider's non-2xx answer, after backoff for retryable statuses. */",
|
||||
" * Particle runs only: the run ended on a turn that timed out, or lost its connection after it went out",
|
||||
" * (`compatMayHaveBilled`). Particle may have billed that turn, so a caller never runs the question again.",
|
||||
" * recorded, and a 2xx that is not JSON or has no `choices[0].message` is retried as a server error. A `compat` request",
|
||||
" * is sent with `compatDeadlineMs` instead of Bun's 300-second fetch timeout, and a 504 (a gateway's or the lease",
|
||||
" last = new ProviderError(502, `${compat} answered ${res.status} ${parsed ? 'without choices[0].message' : 'with a body that is not JSON'}: ${text.slice(0, 300)}`);",
|
||||
" onAttempt?.({ status: 'error', response: json, error: last.message, ms: Date.now() - t, retry_after_ms: null });",
|
||||
" if (attempt < 5) await new Promise(r => setTimeout(r, Math.min(60_000, 2000 * 2 ** attempt) + Math.random() * 1000));",
|
||||
" continue;"
|
||||
],
|
||||
"eval/runner/harness-metering-proxy.ts": [
|
||||
"import { carriesKeyInCleartext, COMPAT_PROVIDERS } from './providers/openai-compatible.ts';",
|
||||
" const message = `metering proxy could not reach ${provider}: ${(error as Error).message}`;",
|
||||
" return finish(errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, 0, message), error: message });",
|
||||
" return finish(errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, upstream.status, message), error: message });"
|
||||
],
|
||||
"eval/runner/providers/openai-compatible.ts": [
|
||||
"/** A non-2xx answer that is not a budget stop. */",
|
||||
" * A request the provider may have billed: it timed out (`compatTimedOut`) or its connection was lost after it went out",
|
||||
" * (`compatConnectionLost`). A caller never sends it again, with or without a metering proxy in front.",
|
||||
" return compatTimedOut(e) || compatConnectionLost(e);",
|
||||
" * decides any retry. A 2xx answer without `choices[0].message` is a server error carrying the usage it reported,",
|
||||
" * never an empty answer. `timeoutMs` (300 s unless given) is the only deadline: Bun's own 300-second fetch timeout is",
|
||||
" * off, so a longer deadline holds.",
|
||||
" catch { throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: true, retryAfterMs: null, body: `response body is not JSON: ${text.slice(0, 200)}` }); }",
|
||||
" if (!Array.isArray(choices) || !choices[0]?.message) throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: true, retryAfterMs: null, body: `response has no choices[0].message: ${text.slice(0, 200)}` }, parsed.usage);"
|
||||
],
|
||||
"eval/runner/q1/cell.ts": [
|
||||
" /** `final`: a try that must not be sent again (an OpenAI-compatible request that timed out or lost its connection, which the provider may have billed). */"
|
||||
],
|
||||
"eval/runner/systems/baselines.ts": [
|
||||
" /** The last request timed out or lost its connection after it went out; Particle may have billed it, so the cell never sends it again. */",
|
||||
" * (`compatDeadlineMs`), and one that times out or loses its connection after it",
|
||||
" * went out (`compatMayHaveBilled`) is a `reader_error` that is never sent again, because Particle may have billed it. Each attempt is recorded, the failed ones with null response"
|
||||
],
|
||||
"eval/workload-suites/bench-infra.ts": [
|
||||
"import { COMPAT_PROVIDERS, CompatHttpError, ParticleCreditExhausted, compatRequest, compatSend, type CompatRequest, type CompatResponse } from '../runner/providers/openai-compatible.ts';",
|
||||
" * not Bun's 300-second fetch timeout, and a request that times out is never sent again.",
|
||||
" const timedOut = error.name === 'TimeoutError' || error.name === 'AbortError';",
|
||||
" const retry = http ? http.retryable && attempt < 5 : !budget && !timedOut && attempt < 4;"
|
||||
],
|
||||
"scripts/particle-canary.ts": [
|
||||
"import { CompatHttpError, assertMetered, compatBaseUrl, compatRequest, compatSend, isLoopbackUrl, type CanarySampling, type CompatLogprob, type CompatRequest } from '../eval/runner/providers/openai-compatible.ts';",
|
||||
"/** Send the canary once per prompt (up to 3 attempts on a 429 or 5xx, honoring a capped Retry-After). A budget stop ends it after its receipt. */",
|
||||
" if (!(e instanceof CompatHttpError && e.retryable) || attempt === ATTEMPTS - 1) break;"
|
||||
]
|
||||
}
|
||||
}
|
||||
+51
-51
@@ -2,56 +2,56 @@
|
||||
"_comment": "Generated SKILL.md files over the limit, pinned at their exact character count (#3199). test/skill-md-size-guard.test.ts fails when a pinned skill grows past its pin or shrinks below it: lower the pin when a skill shrinks and delete it once the skill is within the limit. Raising a pin is an owner decision. The shared-preamble hoist (GSTA-96) moved every tier 2+ skill's shared preamble into sections/preamble.md; the pins below are what remains.",
|
||||
"limit": 100000,
|
||||
"pins": {
|
||||
"agy/gstack-autoplan": 109666,
|
||||
"agy/gstack-plan-design-review": 109116,
|
||||
"agy/gstack-plan-devex-review": 114957,
|
||||
"agy/gstack-plan-eng-review": 143590,
|
||||
"agy/gstack-review": 104276,
|
||||
"codex/gstack-autoplan": 108409,
|
||||
"codex/gstack-plan-design-review": 110473,
|
||||
"codex/gstack-plan-devex-review": 116465,
|
||||
"codex/gstack-plan-eng-review": 144755,
|
||||
"codex/gstack-review": 103799,
|
||||
"copilot/gstack-autoplan": 109109,
|
||||
"copilot/gstack-plan-design-review": 109382,
|
||||
"copilot/gstack-plan-devex-review": 114419,
|
||||
"copilot/gstack-plan-eng-review": 143755,
|
||||
"copilot/gstack-review": 103857,
|
||||
"cursor/gstack-autoplan": 108341,
|
||||
"cursor/gstack-plan-design-review": 107924,
|
||||
"cursor/gstack-plan-devex-review": 113689,
|
||||
"cursor/gstack-plan-eng-review": 142345,
|
||||
"cursor/gstack-review": 122147,
|
||||
"factory/gstack-autoplan": 110619,
|
||||
"factory/gstack-plan-design-review": 111022,
|
||||
"factory/gstack-plan-devex-review": 117052,
|
||||
"factory/gstack-plan-eng-review": 145453,
|
||||
"factory/gstack-review": 125920,
|
||||
"gbrain/gstack-autoplan": 108748,
|
||||
"gbrain/gstack-plan-eng-review": 124972,
|
||||
"gemini/gstack-autoplan": 108899,
|
||||
"gemini/gstack-plan-design-review": 109210,
|
||||
"gemini/gstack-plan-devex-review": 114247,
|
||||
"gemini/gstack-plan-eng-review": 143588,
|
||||
"gemini/gstack-review": 103679,
|
||||
"hermes/gstack-autoplan": 108751,
|
||||
"hermes/gstack-plan-eng-review": 125008,
|
||||
"kiro/gstack-autoplan": 108115,
|
||||
"kiro/gstack-plan-design-review": 107770,
|
||||
"kiro/gstack-plan-devex-review": 113541,
|
||||
"kiro/gstack-plan-eng-review": 142207,
|
||||
"kiro/gstack-review": 121971,
|
||||
"openclaw/gstack-autoplan": 108571,
|
||||
"openclaw/gstack-plan-eng-review": 123487,
|
||||
"opencode/gstack-autoplan": 109090,
|
||||
"opencode/gstack-plan-design-review": 109238,
|
||||
"opencode/gstack-plan-devex-review": 114297,
|
||||
"opencode/gstack-plan-eng-review": 143614,
|
||||
"opencode/gstack-review": 122769,
|
||||
"slate/gstack-autoplan": 108228,
|
||||
"slate/gstack-plan-design-review": 107847,
|
||||
"slate/gstack-plan-devex-review": 113615,
|
||||
"slate/gstack-plan-eng-review": 142276,
|
||||
"slate/gstack-review": 122059
|
||||
"agy/gstack-autoplan": 109657,
|
||||
"agy/gstack-plan-design-review": 109107,
|
||||
"agy/gstack-plan-devex-review": 114948,
|
||||
"agy/gstack-plan-eng-review": 143581,
|
||||
"agy/gstack-review": 104267,
|
||||
"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": 103790,
|
||||
"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": 103848,
|
||||
"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": 122138,
|
||||
"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": 125911,
|
||||
"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": 103670,
|
||||
"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": 121962,
|
||||
"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": 122760,
|
||||
"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": 122050
|
||||
}
|
||||
}
|
||||
@@ -3125,9 +3125,11 @@ describe('Codex generation (--host codex)', () => {
|
||||
expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack');
|
||||
expect(content).toContain('_r=${CODEX_HOME:-~/.codex}/skills/gstack');
|
||||
// Phase 1/2: config reads moved into gstack-skill-start — the fence itself
|
||||
// is the bin asset the preamble must resolve through $GSTACK_BIN, and the
|
||||
// question-preference runtime call still resolves the same way.
|
||||
expect(content).toContain('$GSTACK_BIN/gstack-skill-start');
|
||||
// is the bin asset the preamble resolves through the same roots, as plain
|
||||
// lines (GST-882), and the question-preference runtime call resolves
|
||||
// through $GSTACK_BIN.
|
||||
expect(content).toContain('[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${CODEX_HOME:-~/.codex}/skills/gstack');
|
||||
expect(content).toContain('"$GSTACK_ROOT/bin/gstack-skill-start"');
|
||||
expect(content).toContain('$GSTACK_BIN/gstack-question-preference');
|
||||
// The upgrade-skill doc reference moved into the script's upgrade-flow
|
||||
// block, resolved $0-relative ($_ROOT_DIR) — host-neutral by construction,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* version-source shapes (package-manifest pin, monorepo pin, absent source).
|
||||
*/
|
||||
import { afterEach, 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 { EXIT } from '../lib/headless-artifacts';
|
||||
@@ -163,7 +164,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
|
||||
expect(changed.status).toBe(EXIT.fail);
|
||||
expect(changed.out).toMatch(/^TREE: changed \(pr-b\.txt\)$/m);
|
||||
expect(changed.out).toMatch(/^gate-reuse: not-eligible \(tree changed: pr-b\.txt\) — fix: .* \(GATE_REUSE_NOT_ELIGIBLE\)$/m);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test('--dry-run prints the full diff and writes nothing; --was overrides the mention source', () => {
|
||||
const repo = fixture();
|
||||
@@ -191,7 +192,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
|
||||
const stamped = restamp(repo, ['--next']);
|
||||
expect(stamped.status).toBe(EXIT.ok);
|
||||
expect(stamped.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; record the release decision, then write the CHANGELOG entry text only/m);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
// Value: protects=a bump never repo's --next never invents a MICRO stamp: without --bump or --version it refuses and writes nothing, and an explicit level stamps as before; fails_when=restamp falls back to micro for a repo whose policy names no level;
|
||||
// why_new=GST-380 added bump never; seam=gstack-ship-policy, gstack-version-bump write; no_claim=the claim queue read
|
||||
@@ -454,7 +455,7 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () =>
|
||||
const viaGh = runBin(repo, 'gstack-tree-receipt', ['--from', path.join(repo.work, '.gstack/tmp/gate-ahead.json'), '--predecessor-pr', '1', '--bundle', bundle, '--base', 'main', '--json'], ghShim(repo, { '1': { headRefOid: repo.git('rev-parse', 'pr-a') } }));
|
||||
expect(JSON.parse(viaGh.out).predecessor.moved).toBe(true);
|
||||
expect(JSON.parse(viaGh.out).code).toBe('RESTAMP_PREDECESSOR_MOVED');
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test('a gate-ahead merge that conflicts outside the stamp files is RESTAMP_MERGE_CONFLICT with nothing written', () => {
|
||||
const repo = fixture();
|
||||
@@ -587,3 +588,97 @@ describe('version-source shapes', () => {
|
||||
expect(fs.existsSync(path.join(repo.work, 'VERSION'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-restamp stages what it writes (#3531)', () => {
|
||||
/** Branch b forks from main; branch a stamps and merges first, so b's base merge conflicts only on the CHANGELOG. */
|
||||
function behindMain(): FixtureRepo {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-b');
|
||||
makePr(repo, 'pr-a');
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'stamp pr-a');
|
||||
mergeToMain(repo, 'pr-a');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', 'pr-b');
|
||||
return repo;
|
||||
}
|
||||
const cached = (repo: FixtureRepo) => repo.git('diff', '--cached', '--name-only').split('\n').filter(Boolean);
|
||||
const unstaged = (repo: FixtureRepo) => repo.git('diff', '--name-only').split('\n').filter(Boolean);
|
||||
const filesOf = (out: string) => (/^FILES: (.*)$/m.exec(out)?.[1] ?? '').split(' ').filter(Boolean);
|
||||
|
||||
test('after --next on a staged no-commit base merge, every FILES entry is staged, STAGED: names them, and a bare commit carries the stamp', () => {
|
||||
const repo = behindMain();
|
||||
const merge = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
|
||||
if (merge.status !== 0) {
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(repo.git('show', ':2:CHANGELOG.md') + '\n', repo.git('show', ':3:CHANGELOG.md') + '\n')!);
|
||||
repo.git('add', 'CHANGELOG.md');
|
||||
}
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status, r.err).toBe(EXIT.ok);
|
||||
const files = filesOf(r.out);
|
||||
expect(files).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
|
||||
expect(cached(repo)).toEqual(expect.arrayContaining(files));
|
||||
expect(unstaged(repo).filter(f => files.includes(f))).toEqual([]);
|
||||
const staged = /^STAGED: (.*)$/m.exec(r.out)?.[1]?.split(' ') ?? [];
|
||||
expect(staged).toEqual(expect.arrayContaining(files));
|
||||
repo.git('commit', '-q', '--no-edit');
|
||||
expect(repo.git('show', 'HEAD:VERSION')).toBe('1.2.5.0');
|
||||
expect(repo.git('show', 'HEAD:CHANGELOG.md')).toMatch(/^# Changelog\n\n## \[1\.2\.5\.0\]/);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a stamp file with unstaged edits of its own refuses before writing (RESTAMP_UNSTAGED_EDITS); nothing written or staged', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-c');
|
||||
fs.appendFileSync(path.join(repo.work, 'CHANGELOG.md'), '\n- an edit the caller has not staged\n');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/CHANGELOG\.md holds? unstaged edits of your own.*nothing written.*\(RESTAMP_UNSTAGED_EDITS\)/);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n');
|
||||
expect(cached(repo)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a staging failure prints RESTAMP_NOT_STAGED with the files written and a marker; the noop rerun stages them', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-d');
|
||||
const lock = path.join(repo.work, '.git', 'index.lock');
|
||||
fs.writeFileSync(lock, '');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.err).toContain('(RESTAMP_NOT_STAGED)');
|
||||
expect(r.out).not.toMatch(/^STAGED:/m);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
|
||||
expect(cached(repo)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(true);
|
||||
fs.rmSync(lock, { force: true });
|
||||
const again = restamp(repo, ['--next']);
|
||||
expect(again.status, again.err).toBe(EXIT.ok);
|
||||
expect(again.out).toMatch(/^RESTAMP: .*\(noop;/m);
|
||||
expect(cached(repo)).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
|
||||
expect(again.out).toMatch(/^STAGED: .*VERSION/m);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
|
||||
});
|
||||
|
||||
// fails_when=a staging marker left by a failure the lane then cleared by hand makes every later noop restamp exit 1 with RESTAMP_NOT_STAGED, whose fix (rerun) can never clear it (outside voice, PR #3737); why_new=the test above reruns on the same unchanged files; seam=.git/index.lock; no_claim=how the lane cleared the failure
|
||||
test('a stale staging marker (its files changed since: a hand commit, then another stamped branch) is dropped by the noop rerun instead of failing it', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-a');
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-q', '-m', 'stamp pr-a');
|
||||
makePr(repo, 'pr-d');
|
||||
const lock = path.join(repo.work, '.git', 'index.lock');
|
||||
fs.writeFileSync(lock, '');
|
||||
expect(restamp(repo, ['--next']).err).toContain('(RESTAMP_NOT_STAGED)');
|
||||
fs.rmSync(lock, { force: true });
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'stamp pr-d by hand');
|
||||
repo.git('checkout', '-q', 'pr-a');
|
||||
const noop = restamp(repo, ['--next']);
|
||||
expect(noop.status, noop.err).toBe(EXIT.ok);
|
||||
expect(noop.err).not.toContain('RESTAMP_NOT_STAGED');
|
||||
expect(noop.out).toMatch(/^NOTE: .*CHANGELOG\.md.* changed since an earlier restamp wrote (it|them); not staged/m);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,8 @@ describe('gstack-skill-start contract', () => {
|
||||
// Worktree-isolated Claude Code sessions refuse a start command named by a
|
||||
// variable, a `[ -x ] ||` fallback, or a trailing `|| echo` (reproduced
|
||||
// with the pinned CLI). The Claude render is the literal install path;
|
||||
// env-var hosts reach the script through $GSTACK_BIN. A missing helper is
|
||||
// checked-in env-var host renders reach it through $GSTACK_ROOT/bin (an
|
||||
// install-root render names the literal path, GST-882). A missing helper is
|
||||
// covered by the degraded-mode prose, not a shell fallback.
|
||||
const fence = (text: string) => {
|
||||
const at = text.search(/^## Preamble \((?:run first|after scope gate)\)/m);
|
||||
@@ -130,7 +131,7 @@ describe('gstack-skill-start contract', () => {
|
||||
}
|
||||
for (const host of ['codex', 'gbrain']) {
|
||||
const lines = fence(generatePreamble({ skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', host, paths: HOST_PATHS[host], preambleTier: 4 } as TemplateContext)).split('\n');
|
||||
expect(lines.at(-1)).toMatch(/^"\$GSTACK_BIN\/gstack-skill-start" --skill "ship" --model "[^"]+"( --brain-health)?$/);
|
||||
expect(lines.at(-1)).toMatch(/^"\$GSTACK_ROOT\/bin\/gstack-skill-start" --skill "ship" --model "[^"]+"( --brain-health)?$/);
|
||||
expect(lines.join('\n')).not.toMatch(/_SS|\$PPID|\|\| echo|\[ -x/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ const COPIED = [
|
||||
'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks
|
||||
'bin/gstack-launch-probe.sh',
|
||||
'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts',
|
||||
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', // the windows bash row
|
||||
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', // the windows bash row
|
||||
];
|
||||
const bases: string[] = [];
|
||||
/** Registers the per-test fixture cleanup; each test file calls it at top level (a hook in this module would bind to the first importing file only). */
|
||||
|
||||
@@ -205,6 +205,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-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'],
|
||||
'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'],
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/** Train setups shared by the test/ship-queue-train*.test.ts files (moved from test/ship-queue-train.test.ts, GSTA-151). */
|
||||
import { expect } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { GREEN_GATE, nonceOf, openPr, queue, readyReceipt, type Rule, type TrainFixture } from './ship-queue-train-fixture';
|
||||
import { mergeChangelogs } from '../../lib/changelog-check';
|
||||
import { EXIT } from '../../lib/headless-artifacts';
|
||||
import { runBin } from './restamp-fixture';
|
||||
import { GREEN_GATE, line, nonceOf, openPr, queue, readyReceipt, type Rule, type TrainFixture } from './ship-queue-train-fixture';
|
||||
|
||||
export function twoPrTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) {
|
||||
const h1 = openPr(fx, '1');
|
||||
@@ -27,3 +32,33 @@ export function publishMain(fx: TrainFixture, prefixCommit: string, version: str
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
/** The lane's slot stamp on its pin (main merged in first from slot 2 on): gstack-restamp --next, then the stamp files committed and pushed as the PR head. */
|
||||
export function laneStamp(fx: TrainFixture, pr: string, pin: string, o: { mergeMain?: boolean; dropStamp?: boolean } = {}): string {
|
||||
const { repo } = fx;
|
||||
repo.git('reset', '-q', '--hard');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', '-B', `lane${pr}`, pin);
|
||||
if (o.mergeMain) {
|
||||
const m = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
|
||||
if (m.status !== 0) {
|
||||
const side = (n: number) => repo.git('show', `:${n}:CHANGELOG.md`) + '\n';
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(side(2), side(3), side(1))!);
|
||||
repo.git('add', 'CHANGELOG.md');
|
||||
}
|
||||
}
|
||||
const index = repo.git('write-tree');
|
||||
const stamp = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], fx.env);
|
||||
expect(stamp.status, stamp.err).toBe(EXIT.ok);
|
||||
const files = line(stamp.out, 'FILES:').slice('FILES: '.length).split(' ').filter(Boolean);
|
||||
if (o.dropStamp) repo.git('read-tree', index);
|
||||
else repo.git('add', '-A', '--', ...files);
|
||||
repo.git('commit', '-q', '--no-edit', '-m', `slot stamp #${pr}`);
|
||||
if (o.dropStamp) repo.git('checkout', '-q', '--', '.');
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/${pr}/head`);
|
||||
return head;
|
||||
}
|
||||
|
||||
/** GitHub reports the PR MERGED, which verify --train needs before it confirms a merge (#3531). */
|
||||
export const markMerged = (fx: TrainFixture, pr: string) => fx.write(d => { (d.prs[pr] as any).state = 'MERGED'; });
|
||||
@@ -147,6 +147,41 @@ export function trainJson(fx: TrainFixture, nonce: string, state = fx.state): an
|
||||
}
|
||||
export const dispatches = (fx: TrainFixture, workflow?: string) => fx.read().log.filter(l => l.cmd === 'workflow run' && (!workflow || l.workflow === workflow));
|
||||
|
||||
/** For train land and land: pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
|
||||
export function wrapLand(fx: TrainFixture): void {
|
||||
const dir = path.join(fx.repo.root, 'gh-land-wrap');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
|
||||
fs.writeFileSync(path.join(dir, 'gh'), [
|
||||
'#!/usr/bin/env node',
|
||||
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
|
||||
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
|
||||
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
|
||||
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
|
||||
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
|
||||
" const before=rev('main');let tree=rev(want+'^{tree}');",
|
||||
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
|
||||
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
|
||||
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
|
||||
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
fx.env.PATH = `${dir}:${fx.env.PATH}`;
|
||||
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
|
||||
}
|
||||
|
||||
/** A changelog_fragments PR: a fragment under changelog.d/, pushed as pr-<n>, with a READY receipt. */
|
||||
export function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
|
||||
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
|
||||
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
|
||||
readyReceipt(fx, pr, head);
|
||||
return head;
|
||||
}
|
||||
function shimSource(): string {
|
||||
return [
|
||||
"const fs = require('fs'); const path = require('path'); const cp = require('child_process');",
|
||||
|
||||
@@ -417,7 +417,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'tpa-apple-ban': [ 'scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts', 'test/helpers/third-party-actions.ts',
|
||||
'lib/eval-model.ts'
|
||||
],
|
||||
'ship-measure-seeded-flake': ['ship/**', 'scripts/ship-measure.ts', 'scripts/lib/measure-bar.ts', 'scripts/test-free-shards.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/paid-select.ts', 'scripts/lib/free-home-guard.ts', 'bin/gstack-config', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-ship-measure-loop.test.ts', 'test/helpers/ship-measure-seeded-fixture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/skill-fixture.ts'],
|
||||
'ship-measure-seeded-flake': ['ship/**', 'scripts/ship-measure.ts', 'scripts/lib/measure-bar.ts', 'scripts/test-free-shards.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/paid-select.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/shard-jobs.ts', 'bin/gstack-config', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-ship-measure-loop.test.ts', 'test/helpers/ship-measure-seeded-fixture.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/skill-fixture.ts'],
|
||||
'ship-section-loading': [ 'ship/**', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-ship-section-loading.test.ts',
|
||||
'scripts/resolvers/testing.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/llm-judge.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
|
||||
'plan-ceo-section-loading': [ 'ETHOS.md',
|
||||
|
||||
@@ -70,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
|
||||
// These imports are resolved relative to the copied install, not the source checkout.
|
||||
const libDir = path.join(workDir, 'lib');
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts']) {
|
||||
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts']) {
|
||||
fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ const NO_LOSS_REPLAYS = {
|
||||
'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-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-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",
|
||||
'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",
|
||||
} as const;
|
||||
type Replay = keyof typeof NO_LOSS_REPLAYS;
|
||||
@@ -78,8 +79,8 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||
'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: 'd649569a163c03476c6e0c5ea295f5d02088f96aab85fbc7d558f2ac2b27006a', replay: 'R51' },
|
||||
'lib/review-risk.ts': { sha: 'dbef7475d467e17340b21a2bacb52a16a7ccb1ee66291900ed2d36dfb5cfb7f2', replay: 'R51' },
|
||||
'lib/review-depth.ts': { sha: '839205a409014fb2cca6988a01aa04c98c495d8d24bd94895a86bf7794627338', replay: 'GST-850' },
|
||||
'lib/review-risk.ts': { sha: '18f0330ddfda0032eb93516b0eb941fa4244e0a68b028e19c436c74609093b03', replay: 'GST-850' },
|
||||
};
|
||||
|
||||
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { promptHash, sectionTexts, withWorkspace } from '../lib/review-brief';
|
||||
import { CHUNK_CHARS, buildManifest, chunkCount, chunkOf, markReader, renderStage, specialistFindings, type Manifest } from '../lib/review-pass';
|
||||
import { CHUNK_CHARS, PARENT_ONLY_QA, buildManifest, chunkCount, chunkOf, markReader, renderStage, specialistFindings, type Manifest } from '../lib/review-pass';
|
||||
import type { Dispatch } from '../lib/review-depth';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -87,6 +87,13 @@ describe('pass manifest', () => {
|
||||
expect(m.readers[0]).toMatchObject({ state: 'created', path: '4' });
|
||||
});
|
||||
|
||||
// Value: protects=an unreadable extra prompt stops as a BriefError naming the reader; fails_when=a directory prompt_file throws a raw
|
||||
// EISDIR from the QA guard's read (GST-450 adversarial #4); seam=none
|
||||
test('a directory extra prompt stops as an unreadable prompt file, not a raw read error', () => {
|
||||
const d = manifest([spec('testing')], { extras: [{ name: 'plan', prompt_file: tmp('rp-qa-') }] });
|
||||
expect(() => renderStage(d, 'specialists', texts, () => '')).toThrow(/cannot read audit\/plan's prompt file/);
|
||||
});
|
||||
|
||||
test('a brief over 1 MiB with invalid UTF-8 in its diff travels in bounded chunks that rejoin to the rendered sha', () => {
|
||||
const m = manifest([{ role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]);
|
||||
const diff = Buffer.concat([Buffer.from('+line \u00e9\n'.repeat(90_000)), Buffer.from([0xff, 0xfe, 0x0a]), Buffer.from('+"quoted" \\ back\n'.repeat(20_000))]).toString('utf8');
|
||||
@@ -504,16 +511,33 @@ describe('Capy pass program (fake host, real bins)', () => {
|
||||
const elsewhere = tmp('rp-cwd-');
|
||||
const out = path.join(dir, '.state/run/p1');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
fs.writeFileSync(path.join(out, 'qa.prompt.md'), 'QA brief');
|
||||
fs.writeFileSync(path.join(out, 'plan.prompt.md'), 'Plan completion brief');
|
||||
const h = host(elsewhere, env, { lateSummary: true });
|
||||
const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'],
|
||||
extras: [{ name: 'qa', prompt_file: path.join(out, 'qa.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
|
||||
extras: [{ name: 'plan', prompt_file: path.join(out, 'plan.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
|
||||
expect(res).not.toContain('STOP:');
|
||||
expect(res).toContain('NEXT: judge (Phase 4)');
|
||||
expect(h.creates.find(c => c.title === 'ship x13p1 audit/qa')!.prompt).toBe(withWorkspace('QA brief', fs.realpathSync(dir)));
|
||||
expect(h.creates.find(c => c.title === 'ship x13p1 audit/plan')!.prompt).toBe(withWorkspace('Plan completion brief', fs.realpathSync(dir)));
|
||||
}, 180_000);
|
||||
|
||||
|
||||
// Value: protects=Step 9.2.1's probes stay the parent's, as its section says, so QA coverage is never a subagent's refusal; fails_when=the
|
||||
// pass program dispatches a QA extra (C4-36, C5-36: the QA reader refused both passes, "parent-only", and the parent ran the probes after it);
|
||||
// why_new=extras took any prompt (GST-450); seam=the pass program; no_claim=the parent's probes themselves (DRIVER Phase 4)
|
||||
test('a QA extra, by name or by its section\'s parent-only brief, stops the pass before any reader is created', async () => {
|
||||
const section = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8');
|
||||
expect(section).toContain(PARENT_ONLY_QA);
|
||||
const brief = section.slice(section.indexOf('### Step 9.2.1'), section.indexOf('### Step 9.3'));
|
||||
for (const [name, text] of [['qa', 'QA brief'], ['exploratory', brief]] as const) {
|
||||
const { dir, env } = repo();
|
||||
const out = path.join(dir, '.state/run/p1');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
fs.writeFileSync(path.join(out, name + '.prompt.md'), text);
|
||||
const h = host(dir, env);
|
||||
const res = await runPass(h, { base: 'main', out, pass: 'x14p1', planFlags: ['--depth-floor', 'full'],
|
||||
extras: [{ name, prompt_file: path.join(out, name + '.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
|
||||
expect([name, res.includes('STOP:'), res.includes("Step 9.2.1 is the parent's report-only discovery"), h.creates.length]).toEqual([name, true, true, 0]);
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
test('decision 3.5 runs in the program: one fix-check reader whose prompt is the render CLI brief byte for byte, sent and collected', async () => {
|
||||
const { dir, env } = repo();
|
||||
@@ -704,8 +728,8 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
|
||||
const out = path.join(dir, '.state/run/p2');
|
||||
const extras = path.join(dir, '.state/extras.json');
|
||||
fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.state/qa.md'), 'QA the fix.\n');
|
||||
fs.writeFileSync(extras, JSON.stringify([{ name: 'qa', prompt_file: path.join(dir, '.state/qa.md') }]));
|
||||
fs.writeFileSync(path.join(dir, '.state/plan.md'), 'Check the plan items against the fix.\n');
|
||||
fs.writeFileSync(extras, JSON.stringify([{ name: 'plan', prompt_file: path.join(dir, '.state/plan.md') }]));
|
||||
const args = ['pass', '--base', 'main', '--out', out, '--pass', 'p2', '--extras', extras, '--', '--pass', '1', '--last', 'full', '--pass1-depth', 'standard', '--fixed-blocking', ...flags, '--range-base', rangeBase.slice(0, 7)];
|
||||
const p = bin(dir, env, args);
|
||||
expect(p.status, p.stdout + p.stderr).toBe(0);
|
||||
@@ -727,7 +751,7 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
|
||||
const s = scoped([]);
|
||||
expect(s.m.next).toBe('scoped');
|
||||
expect(s.m.diff_base).toBe(s.mergeBase);
|
||||
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['outside-voice', null], ['audit/qa', null]]);
|
||||
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['outside-voice', null], ['audit/plan', null]]);
|
||||
settle(s, 'specialists');
|
||||
const m = settle(s, 'red-team');
|
||||
const redTeam = fs.readFileSync(m.readers.find(r => r.reader === 'red-team')!.file!, 'utf8');
|
||||
@@ -745,7 +769,7 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
|
||||
test('after Step 11 ran (--adversarial-ran), the whole scoped pass reads the fix range', () => {
|
||||
const s = scoped(['--adversarial-ran']);
|
||||
expect(s.m.diff_base).toBe(s.rangeBase);
|
||||
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/qa', null]]);
|
||||
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/plan', null]]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* GST-850: every risk pattern (lib/review-risk.ts) finishes in linear time on hostile input, and every new one is in
|
||||
* RISK_RULES, which DEPTH_RULES folds in (docs/review-policy.md#risk-classes).
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECURITY_LINE, matchRisk } from '../lib/review-risk';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const N = 10_000;
|
||||
const fast = (f: () => unknown) => { const t = performance.now(); f(); return performance.now() - t; };
|
||||
const scan = (lines: string[]) => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': lines }, removed: { 'src/a.ts': lines }, scopeAuth: false });
|
||||
|
||||
describe('linear time on hostile input', () => {
|
||||
// Value: protects=gstack-review-plan and the queue verdict never hang on a diff (the R51 resource regex did at about 50
|
||||
// backslashes; GST-850 rounds 1 and 2 found quadratic import and exec-binding scans); fails_when=any risk pattern
|
||||
// backtracks on a long or minified line, or on its anchor followed by a long run of the class it repeats; seam=every
|
||||
// pattern in SECURITY_LINE, DATA_LINE, SECRET_LINE and MONEY_LINE (R51's rm rule is a known pre-existing issue, filed apart), and matchRisk
|
||||
const L = 200_000;
|
||||
const fill = (s: string) => s.repeat(Math.ceil(L / s.length));
|
||||
const hostile = [' ', '(', "'", '"', 'a', '{', '\\', '..', 'import a, {', 'fetch(', 'path.join(', 'rmSync(', ' exec', 'new URL("', 'child_process', 'http.get(', 'requests.',
|
||||
'tar -', 'unzip ', 'DELETE FROM ', 'delete from a ', 'process.env.', 'x_usd', 'aUsd'].map(fill).concat([' exec' + ' '.repeat(L) + '// c', 'import a, {' + 'b,'.repeat(L / 2)],
|
||||
['fetch', 'globalThis.fetch', 'http.get', 'https.request', 'got', 'urlopen', 'urljoin', 'url.resolve', 'exec', 'execSync', 'cp.exec', 'proxy', 'redirect', 'maxRedirects', 'tar.extract', 'unzip', 'tar'].map(a => a + ' '.repeat(L)),
|
||||
['got.', 'requests.', 'httpx.', 'tar -', 'tar x', 'rm -', 'allowed_', 'host_allow', 'node_modules/adm-'].map(a => a + 'x'.repeat(L)),
|
||||
['new' + ' '.repeat(L) + 'URL(', 'new URL(' + ' '.repeat(L), 'new URL("' + 'a'.repeat(L), "'" + 'node:'.repeat(L / 5), 'path.join(' + 'a'.repeat(L), 'path.join(' + ')'.repeat(L),
|
||||
"includes(" + ' '.repeat(L), '==' + ' '.repeat(L) + "'..'", "'..'" + ' '.repeat(L) + '==', '\\.' + '\\'.repeat(L)]);
|
||||
test.each(Object.entries({ SECURITY_LINE, DATA_LINE, SECRET_LINE, MONEY_LINE }))('every %s pattern finishes under 100 ms on each 200k-character hostile line', (_n, patterns) => {
|
||||
const slow = patterns.flatMap(r => hostile.map((s, k) => [r.source.slice(0, 40), k, fast(() => r.test(s))] as const)).filter(([, , ms]) => ms >= 100);
|
||||
expect(slow).toEqual([]);
|
||||
});
|
||||
test('a 512 KB comment of unclosed imports and a minified import line run through matchRisk in under 1 s', () => {
|
||||
expect(fast(() => scan(Array.from({ length: 65_536 }, () => 'import {')))).toBeLessThan(1000);
|
||||
expect(fast(() => scan(['import a, {'.repeat(32_000)]))).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the rules hashes cover every new pattern', () => {
|
||||
// Value: protects=a receipt or queue verdict notices a risk-pattern edit; fails_when=an edit to a security pattern or a money
|
||||
// word leaves DEPTH_RULES unchanged; seam=DEPTH_RULES over RISK_RULES
|
||||
test.each([
|
||||
["'billable', 'billed'", "'billable', 'billed', 'tariff'"],
|
||||
['extract-zip|unzipper', 'extract-zip|unzipper|jszip'],
|
||||
["['proxy', ['billing', 'bill']]", "['proxy', ['billing', 'bill', 'meter']]"],
|
||||
])('editing review-risk.ts (%s) changes DEPTH_RULES', (needle, to) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'risk-rules-'));
|
||||
try {
|
||||
for (const f of ['review-depth.ts', 'review-risk.ts']) fs.copyFileSync(path.join(ROOT, 'lib', f), path.join(dir, f));
|
||||
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 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
const before = hash();
|
||||
const src = fs.readFileSync(path.join(dir, 'review-risk.ts'), 'utf8');
|
||||
expect(src).toContain(needle);
|
||||
fs.writeFileSync(path.join(dir, 'review-risk.ts'), src.replace(needle, to));
|
||||
expect(before).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(hash()).not.toBe(before);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
@@ -121,3 +121,183 @@ describe('the receipt records the tier and the rounds', () => {
|
||||
expect(receiptSummaryLine(legacy)).not.toContain('rounds=');
|
||||
});
|
||||
});
|
||||
|
||||
const SVG_PATH = 'docs/evals/chart.svg';
|
||||
const CHART = ['<?xml version="1.0" encoding="UTF-8"?>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" role="img" aria-label="recall by arm">', ' <title>Recall by arm</title>',
|
||||
' <defs><linearGradient id="g"><stop offset="0" stop-color="#4a90d9"/><stop offset="1" stop-color="#1c5fa8"/></linearGradient></defs>', ' <g font-family="sans-serif" font-size="10">',
|
||||
' <rect x="10" y="20" width="40" height="70" fill="url(#g)" style="opacity: 0.9"/>', ' <path d="M10 90 H190" stroke="#333" stroke-width="1"/>', ' <text x="30" y="15" text-anchor="middle">33/41 & up</text>', ' <use href="#g"/>', ' </g>', '</svg>', ''];
|
||||
const classOf = (p: string, added: string[], removed: string[] = [], created: string[] = []) =>
|
||||
classifyDiff({ paths: [p], created, gateTagged: 0, added: { [p]: added }, removed: { [p]: removed }, perPath: [{ path: p, lines: added.length + removed.length, binary: false }] }).class;
|
||||
const svgClass = (lines: string[]) => classOf(SVG_PATH, lines, [], [SVG_PATH]);
|
||||
const swap = (from: string, to: string) => CHART.map(l => l.replace(from, to));
|
||||
|
||||
describe('GST-850 choice A: a clean chart SVG and a locale value edit stay code (light)', () => {
|
||||
// Value: protects=UI text and SVGs keep one batched reader plus the native adversarial pass (the allowlist docs paths were dropped
|
||||
// for review cost); fails_when=a clean chart or a locale value edit plans as docs, or the batched specialist drops a write-up
|
||||
// that publishes numbers; seam=classifyDiff and planReview
|
||||
test('a new clean chart SVG is code, and a write-up with it plans light with the batched reader', () => {
|
||||
expect(svgClass(CHART)).toBe('code');
|
||||
const md = 'docs/evals/run.md';
|
||||
const added = { [md]: ['Recall rose to 33/41.'], [SVG_PATH]: CHART };
|
||||
const p = plan({ paths: [md, SVG_PATH], created: [SVG_PATH], added, removed: {}, perPath: [{ path: md, lines: 1, binary: false }, { path: SVG_PATH, lines: CHART.length, binary: false }], numbers: scanNumbers(added) });
|
||||
expect([p.depth, roles(p)]).toEqual(['light', ['specialist', 'adversarial-native', 'outside-voice']]);
|
||||
});
|
||||
test('the batched specialist reads a write-up that publishes numbers, and leaves docs out when nothing is published', () => {
|
||||
const md = 'docs/evals/run.md', code = 'scripts/score.ts';
|
||||
const base = { paths: [code, md], added: { [code]: ['export const k = 1;'], [md]: ['Recall rose to 33/41.'] }, removed: {}, perPath: [{ path: code, lines: 1, binary: false }, { path: md, lines: 1, binary: false }] };
|
||||
const batched = (numbers: ReturnType<typeof scanNumbers>) => plan({ ...base, numbers }).dispatches.find(d => d.name === 'batched')!.exclude.join(' ');
|
||||
expect(batched(scanNumbers(base.added))).not.toContain(md);
|
||||
expect(batched(none)).toContain(md);
|
||||
});
|
||||
test.each([
|
||||
['json', 'locales/en.json', ' "save": "Save",', ' "save": "Save now",'],
|
||||
['arb', 'lib/l10n/app_en.arb', ' "greeting": "Hello {name}",', ' "greeting": "Hi there {name}",'],
|
||||
['yaml quoted', 'app/i18n/en.yml', ' save: "Save"', ' save: "Save now"'],
|
||||
['yaml plain', 'locales/en.yaml', ' cancel: Cancel', ' cancel: Cancel it'],
|
||||
['po', 'locale/fr/LC_MESSAGES/app.po', 'msgstr "Enregistrer %s"', 'msgstr "Sauvegarder %s"'],
|
||||
['strings', 'Resources/lang/en.lproj/Localizable.strings', '"save" = "Save";', '"save" = "Save now";'],
|
||||
['xliff', 'translations/messages.fr.xlf', ' <target>Enregistrer</target>', ' <target>Sauvegarder</target>'],
|
||||
['ftl', 'locales/en-US/main.ftl', 'welcome = Welcome, { $user }!', 'welcome = Hello, { $user }!'],
|
||||
['properties', 'src/main/resources/i18n/messages_en.properties', 'button.save=Save', 'button.save=Save now'],
|
||||
])('a %s value edit under a locale directory is code', (_n, p, before, after) => {
|
||||
expect(classOf(p, [after], [before])).toBe('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-850: every bypass of the line-pattern docs paths stays code', () => {
|
||||
// Value: protects=active or bound content never plans as docs; fails_when=any bypass from GST-850 or a sibling spelling classes docs;
|
||||
// seam=classifyDiff
|
||||
test.each([
|
||||
['namespaced <s:script>', ['<svg xmlns:s="http://www.w3.org/2000/svg"><s:script>window.x=42</s:script></svg>']],
|
||||
['a <script> element', swap('<title>', '<script>alert(1)</script><title>')],
|
||||
['an uppercase SCRIPT', swap('<title>', '<SCRIPT>alert(1)</SCRIPT><title>')],
|
||||
['a multi-line onload attribute', ['<svg xmlns="http://www.w3.org/2000/svg"', ' onload', ' ="alert(1)">', '</svg>']],
|
||||
['an onclick handler', swap('<rect ', '<rect onclick="alert(1)" ')],
|
||||
['a Vue :fill binding', swap('<rect ', '<rect :fill="leak(document.cookie)" ')],
|
||||
['a v-bind: binding', swap('<rect ', '<rect v-bind:fill="x" ')],
|
||||
['an Alpine x-bind: binding', swap('<rect ', '<rect x-bind:fill="x" ')],
|
||||
['an @click binding', swap('<rect ', '<rect @click="x()" ')],
|
||||
['mustache text', swap('33/41 & up', '{{ constructor.constructor("alert(1)")() }}')],
|
||||
['a foreignObject', swap('<title>', '<foreignObject><div>x</div></foreignObject><title>')],
|
||||
['an animate on href', swap('<use href="#g"/>', '<use href="#g"><animate attributeName="href" to="javascript:alert(1)"/></use>')],
|
||||
['a set element', swap('<use href="#g"/>', '<set attributeName="fill" to="red"/>')],
|
||||
['a doctype with an entity', ['<!DOCTYPE svg [<!ENTITY x "y">]>', ...CHART.slice(1)]],
|
||||
['an xlink:href', swap('<use href="#g"/>', '<use xlink:href="#g"/>')],
|
||||
['an outside use href', swap('<use href="#g"/>', '<use href="https://evil.example/x.svg#a"/>')],
|
||||
['an href off use', swap('<text x="30"', '<text href="#g" x="30"')],
|
||||
['a style url(', swap('style="opacity: 0.9"', 'style="fill: url(#g)"')],
|
||||
['a style @import', swap('style="opacity: 0.9"', 'style="@import url(x.css)"')],
|
||||
['an outside fill url', swap('fill="url(#g)"', 'fill="url(https://evil.example/x)"')],
|
||||
['a CSS-escaped url', swap('fill="url(#g)"', 'fill="\\75rl(https://evil.example/x)"')],
|
||||
['an xml-stylesheet instruction', ['<?xml-stylesheet href="https://evil.example/x.css"?>', ...CHART.slice(1)]],
|
||||
['a CDATA section', swap('33/41 & up', '<![CDATA[<script>x</script>]]>')],
|
||||
['an image element', swap('<title>', '<image href="https://evil.example/x.png"/><title>')],
|
||||
['a style element', swap('<title>', '<style>@import url(x.css);</style><title>')],
|
||||
['another namespace', swap('xmlns="http://www.w3.org/2000/svg"', 'xmlns="http://www.w3.org/1999/xhtml"')],
|
||||
['a numeric entity in an attribute', swap('fill="url(#g)"', 'fill="url(https://evil.example/x)"')],
|
||||
['an unclosed root', CHART.slice(0, -2)],
|
||||
['two roots', [...CHART, '<svg xmlns="http://www.w3.org/2000/svg"></svg>']],
|
||||
['text after the root', [...CHART, 'trailing']],
|
||||
['an HTML-closed empty comment <!-->', swap('<title>', '<!--><image width="1" height="1"/>--><title>')],
|
||||
['an HTML-closed empty comment <!--->', swap('<title>', '<!---><foreignObject/>--><title>')],
|
||||
['numeric references spelling an interpolation', swap('33/41 & up', '{{run()}}')],
|
||||
['an attribute interpolation', swap('<text x="30"', '<text class="{{run()}}" x="30"')],
|
||||
])('a new SVG with %s is code', (_n, lines) => {
|
||||
expect(svgClass(lines as string[])).toBe('code');
|
||||
});
|
||||
test('an edited SVG is code even when both sides are clean', () => {
|
||||
expect(classOf(SVG_PATH, CHART, swap('33/41', '30/41'))).toBe('code');
|
||||
expect(classOf(SVG_PATH, CHART)).toBe('code');
|
||||
});
|
||||
test.each([
|
||||
['i18n .ts', 'src/i18n/en.ts', " save: 'Save',", " save: 'Save now',"],
|
||||
['string-concat HTML', 'src/App.tsx', "const html = '<h1>' + title + '</h1>';", "const html = '<h1>' + leak() + '</h1>';"],
|
||||
['an edit inside an existing <script>', 'src/App.vue', ' const label = "Save";', ' const label = "Save now";'],
|
||||
['a Vue :title binding', 'src/App.vue', '<div :title="user.name">Profile</div>', '<div :title="leak(document.cookie)">Profile</div>'],
|
||||
['a markup value', 'locales/en.json', ' "save": "Save",', ' "save": "<img src=x onerror=alert(1)>",'],
|
||||
['a changed placeholder', 'locales/en.json', ' "hi": "Hi {name}",', ' "hi": "Hi {user.password}",'],
|
||||
['a new mustache expression', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi {{constructor.constructor(1)()}}",'],
|
||||
['a vue-i18n linked message', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi @:secret.key",'],
|
||||
['an i18next nesting', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi $t(secret)",'],
|
||||
['a renamed key', 'locales/en.json', ' "save": "Save",', ' "store": "Save",'],
|
||||
['an escaped character', 'locales/en.json', ' "save": "Save",', ' "save": "\\u003cscript\\u003e",'],
|
||||
['a script URL', 'locales/en.yml', ' link: "https://example.com"', ' link: "javascript:alert(1)"'],
|
||||
['a locale file outside a locale directory', 'src/en.json', ' "save": "Save",', ' "save": "Save now",'],
|
||||
['a locale file under config/', 'config/locales/en.yml', ' save: "Save"', ' save: "Save now"'],
|
||||
['an escaped script scheme', 'locales/en.json', ' "go": "Continue",', ' "go": "java\\nscript:alert(1)",'],
|
||||
['a protocol-relative link', 'locales/en.json', ' "help": "[docs](/help)",', ' "help": "[docs](//evil.example/help)",'],
|
||||
['a YAML null', 'locales/en.yml', ' label: Save', ' label: null'],
|
||||
['a YAML boolean', 'locales/en.yml', ' label: Save', ' label: false'],
|
||||
['a renamed gettext placeholder', 'locale/fr/LC_MESSAGES/app.po', 'msgstr "Bonjour %(name)s"', 'msgstr "Bonjour %(password)s"'],
|
||||
['an Ansible task under a locale role', 'roles/locale/tasks/main.yml', ' command: a-tool one', ' command: b-tool two'],
|
||||
['a non-message .properties under lang/', 'src/main/resources/lang/app.properties', 'db.url=host-a', 'db.url=host-b'],
|
||||
['a non-locale JSON under lang/', 'tools/lang/rules.json', ' "pattern": "^[a-z]+$",', ' "pattern": ".*",'],
|
||||
['a locale value carrying a risk line', 'src/i18n/en.yml', ' command: echo ok', ' command: rm -rf /tmp/x'],
|
||||
])('%s is code', (_n, p, before, after) => {
|
||||
expect(classOf(p, [after], [before])).toBe('code');
|
||||
});
|
||||
test('an added key and the backtracking input are code, and the input finishes fast', () => {
|
||||
expect(classOf('locales/en.json', [' "extra": "Extra",'])).toBe('code');
|
||||
expect(classOf('src/i18n/en.yml', [' title: Hello', ' sub: World'], [' title: Hello', ' sub: World'])).toBe('code');
|
||||
const t = performance.now();
|
||||
expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(10_000) + 'x'], [' save: "Save"'])).toBe('code');
|
||||
expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(9_999) + '"'], [' save: "Save"'])).toBe('code');
|
||||
expect(performance.now() - t).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-850: security reaches outbound URLs, archives, path traversal and every child_process spelling', () => {
|
||||
// Value: protects=an SSRF, archive-extraction, traversal or shell-exec diff reaches the full panel; fails_when=a spelling of any of
|
||||
// them reads light, or a literal URL, a spawnSync import or a tarball create raises a class; seam=matchRisk
|
||||
test.each([
|
||||
"import { exec } from 'node:child_process';", 'import { execSync as run } from "child_process";', "import { spawn, spawnSync } from 'node:child_process';",
|
||||
"import cp from 'node:child_process';", "import * as cp from 'child_process';", "import cp, { spawnSync } from 'child_process';",
|
||||
"const { exec } = require('child_process');", "const cp = require('node:child_process');", "const cp = await import('node:child_process');",
|
||||
'exec(cmd, cb);', 'cp.exec(cmd);', "import { spawnSync } from 'node:child_process';", "import type { ExecOptions } from 'node:child_process';",
|
||||
"const r = await fetch('https://api.github.com/repos');", 'r = requests.request("GET", user_url)', "http.get('http://x.example/', { hostname: req.query.t }, cb);", 'const res = await fetch(url);', "await fetch('https://' + host + '/v1');", 'https.get(target, cb);', 'await axios.get(req.query.u);', 'const body = await got(url).text();',
|
||||
'r = requests.get(url, timeout=5)', 'urllib.request.urlopen(target)', 'const next = new URL(req.query.next, base);', "import { request } from 'undici';",
|
||||
"const res = await fetch('https://x.example', { redirect: 'follow' });", 'maxRedirects: 10,', 'r = httpx.get("https://x.example", follow_redirects=True)', 'const p = process.env.HTTPS_PROXY;', 'const agent = new HttpsProxyAgent(p);',
|
||||
"const ALLOWED_HOSTS = ['api.example.com'];", 'hostAllowlist.push(h);',
|
||||
'await tar.x({ file, cwd: dest });', 'new AdmZip(buf).extractAllTo(dir, true);', "import yauzl from 'yauzl';", "import extract from 'extract-zip';", 'zipfile.ZipFile(p).extractall(d)',
|
||||
'tarfile.open(p).extractall(d)', 'tar -xzf "$TGZ" -C "$DIR"', 'unzip -o "$ZIP" -d "$DIR"', 'fs.writeFileSync(path.join(root, entry.path), data);', 'const dest = path.resolve(out, req.params.name);',
|
||||
"if (rel.startsWith('..')) throw new Error('escape');", "if (parts.includes('..')) return;",
|
||||
])('%s is security', line => {
|
||||
expect(risk(['src/net.ts'], { 'src/net.ts': [line] })).toEqual(['security']);
|
||||
});
|
||||
test('a named import split over lines is security, from either side of the diff', () => {
|
||||
const split = ['import {', ' spawnSync,', ' exec,', "} from 'node:child_process';"];
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': split })).toEqual(['security']);
|
||||
expect([...matchRisk({ codePaths: ['src/run.ts'], added: {}, removed: { 'src/run.ts': split }, scopeAuth: false }).keys()]).toEqual(['security']);
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': ['import {', ' type ExecOptions,', ' exec as run,', "} from 'node:child_process';"] })).toEqual(['security']);
|
||||
});
|
||||
test('a literal new URL, tarball creation, parent-dir joins and look-alike names are not security', () => {
|
||||
for (const line of ["const u = new URL('https://example.com');", 'const here = new URL(import.meta.url);', 'tar -czf out.tgz dir', "const root = path.join(import.meta.dir, '..');",
|
||||
'const m = re.exec(line);', "ENV.fetch('HOME')", 'git fetch origin main'])
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-850: money reaches metering, charge and credit code', () => {
|
||||
// Value: protects=a billing-client or metering diff gets the money reader; fails_when=gbrain-evals #289 reads required=none again,
|
||||
// or costume, a CLI usage line or a proxy with no billing names money; seam=matchRisk and targetedClasses
|
||||
test('gbrain-evals #289 (metering proxy and particle billing clients) reads money, each code path on its own', () => {
|
||||
const x = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-risk/gbrain-evals-289.json'), 'utf8'));
|
||||
expect(targetedClasses({ paths: x.paths, added: x.added, removed: x.removed, scope: { SCOPE_AUTH: 'false' }, gateTagged: 0 }).classes).toEqual(['money']);
|
||||
expect(classifyDiff({ paths: x.paths, added: x.added, removed: x.removed, gateTagged: 0 }).risk).toContain('money');
|
||||
const code = Object.keys({ ...x.added, ...x.removed });
|
||||
expect(code.length).toBe(7);
|
||||
for (const p of code) expect(matchRisk({ codePaths: [p], added: { [p]: x.added[p] ?? [] }, removed: { [p]: x.removed[p] ?? [] }, scopeAuth: false }).has('money')).toBe(true);
|
||||
});
|
||||
test('metering, charge and credit words and pairs name money; costume, CLI usage and a plain proxy do not', () => {
|
||||
expect(risk(['eval/runner/harness-metering-proxy.ts'])).toEqual(['money']);
|
||||
expect(risk(['lib/usage-tokens.ts'])).toEqual(['money']);
|
||||
for (const line of ['if (compatMayHaveBilled(e)) break;', 'const left = credits - spent;', 'await meter.record(n);', 'const usd = res.usage.output_tokens * rate;',
|
||||
'const billingProxy = startProxy();', 'export class CostClient {', 'const billable = true;'])
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual(['money']);
|
||||
expect(risk(['src/costume.ts'])).toEqual([]);
|
||||
expect(risk(['src/cli/usage.ts'], { 'src/cli/usage.ts': ['export function printUsage() { console.log(USAGE); }'] })).toEqual([]);
|
||||
expect(risk(['src/proxy.ts'], { 'src/proxy.ts': ['const target = proxyTarget(host);'] })).toEqual([]);
|
||||
expect(risk(['src/geometry.ts'], { 'src/geometry.ts': ['const diameter = 2 * r; // a parameter'] })).toEqual([]);
|
||||
expect(classifyDiff({ paths: ['docs/usage.md'], gateTagged: 0, added: { 'docs/usage.md': ['Usage: gstack run'] }, removed: {} }).risk).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -34,8 +34,6 @@ const KNOWN_SITES: Record<string, string> = {
|
||||
'bin/gstack-issue-links': 'outside this wave: the gstack-issue-guard spawn moves to runScriptSync',
|
||||
// Found when the bin-constant rule was added (fix wave PR 5, item 10b).
|
||||
'bin/gstack-brain-cache': 'outside this wave: the gstack-config salience read moves to runScriptSync',
|
||||
// Found when the scanner learned the import.meta.url bin path (#3541).
|
||||
'bin/gstack-ship-receipt': 'the gstack-session-kind spawn moves to runScriptSync in the #3541 ship-receipt follow-up',
|
||||
'lib/autoplan-admission.ts': 'returns before the spawn on win32; the bin/gstack-lease touch moves to runScriptSync with that bin\'s owner',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Sub-shards inside one CI job (`--ci-run … --jobs k`, scripts/lib/shard-jobs.ts):
|
||||
* the k = 1 golden path, identity-bound assignment, rejection of missing,
|
||||
* duplicate and incomplete sub-shard results, one retry pass per CI shard,
|
||||
* serial TREE_MUTATING and SLOW_UNDER_LOAD files, the timeout text, and per-file
|
||||
* test and expect() counts from real bun children at k = 1 and k > 1.
|
||||
*/
|
||||
import { describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, verifyFreeCiResults,
|
||||
type FreeCiResult, type FreeShardOutcome,
|
||||
} from '../scripts/test-free-shards';
|
||||
import {
|
||||
assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts,
|
||||
readJunitReport, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, subShardTimeoutText, validateJobsForShard, type CiShardDeps, type RunShardOptions,
|
||||
} from '../scripts/lib/shard-jobs';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const EXCLUSIVE = Object.keys(TREE_MUTATING)[0];
|
||||
const files = Array.from({ length: 7 }, (_, i) => `test/sample-${i}.test.ts`);
|
||||
const durations = Object.fromEntries(files.map((file, i) => [file, (i + 1) * 1_000]));
|
||||
const context = { planPath: 'plan.json', runId: '123/attempt 2', runner: 'runner-7 (linux, 8 CPUs)' };
|
||||
|
||||
const passed = (shardFiles: string[], shard = 1): FreeShardOutcome => ({
|
||||
shard, files: shardFiles, status: 'passed', exitCode: 0, elapsedMs: 5, groupPid: null, failingFiles: [], unattributedFailures: 0,
|
||||
summary: { testsRan: shardFiles.length * 2, filesRan: shardFiles.length, sawTerminalSummary: true },
|
||||
});
|
||||
const failed = (shardFiles: string[], failing: string[], shard = 1): FreeShardOutcome => ({
|
||||
...passed(shardFiles, shard), status: 'failed', exitCode: 1, failingFiles: failing,
|
||||
});
|
||||
|
||||
function harness(shardFiles: string[], respond: (files: string[]) => FreeShardOutcome | Promise<FreeShardOutcome> = passed) {
|
||||
const calls: Array<{ files: string[]; shardNumber: number; totalShards: number; options: RunShardOptions; startedAt: number; endedAt?: number }> = [];
|
||||
const retries: FreeShardOutcome[][] = [];
|
||||
const guards: Array<{ files: string[]; shards: number }> = [];
|
||||
let homeChange: string | null = null;
|
||||
let terminate = false;
|
||||
const deps: CiShardDeps = {
|
||||
async runShard(runFiles, shardNumber, totalShards, options) {
|
||||
const call: (typeof calls)[number] = { files: runFiles, shardNumber, totalShards, options, startedAt: performance.now() };
|
||||
calls.push(call);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
try { return await respond(runFiles); } finally { call.endedAt = performance.now(); }
|
||||
},
|
||||
async retry(outcomes) {
|
||||
retries.push(outcomes);
|
||||
return { exitCode: outcomes.every(outcome => outcome.status === 'passed') ? 0 : 1, retry: null };
|
||||
},
|
||||
pack: (packFiles, count) => Array.from({ length: count }, (_, i) => packFiles.filter((_, j) => j % count === i)),
|
||||
isSerial: file => file in TREE_MUTATING || file in SLOW_UNDER_LOAD,
|
||||
terminationRequested: () => terminate,
|
||||
phaseGuard: (guarded, shards) => { guards.push({ files: guarded, shards }); return { verify: () => homeChange }; },
|
||||
log: () => {},
|
||||
};
|
||||
const plan = { version: 1 as const, revision: 'revision-a', id: 'plan-id-for-tests', shards: [{ shard: 1, files: shardFiles, predictedMs: 0 }] };
|
||||
const shard = plan.shards[0];
|
||||
const run = (jobs: number) => runCiShard({ plan, shard, revision: 'revision-a', jobs, wallTimeoutMs: 360_000, verbose: false, context }, deps);
|
||||
return { calls, retries, guards, deps, plan, shard, run, setHomeChange: (value: string) => { homeChange = value; }, cancel: () => { terminate = true; } };
|
||||
}
|
||||
|
||||
describe('--jobs validation (FREE_JOBS_INVALID)', () => {
|
||||
test('accepts positive whole numbers and rejects everything else with the code and the precedence note', () => {
|
||||
expect(parseJobsFlag('1')).toBe(1);
|
||||
expect(parseJobsFlag('4')).toBe(4);
|
||||
for (const raw of ['0', '-1', '2.5', 'abc', '', ' 2', undefined]) {
|
||||
expect(() => parseJobsFlag(raw)).toThrow(FREE_JOBS_INVALID);
|
||||
expect(() => parseJobsFlag(raw)).toThrow(/GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run/);
|
||||
}
|
||||
});
|
||||
|
||||
test('--jobs belongs to --ci-run only, and an absent --jobs leaves the parsed options unchanged', () => {
|
||||
const base = ['--ci-run', 'plan.json', '--shard', '1', '--result', 'r.json'];
|
||||
expect(parseCliOptions([...base, '--jobs', '4']).jobs).toBe(4);
|
||||
expect('jobs' in parseCliOptions(base)).toBe(false);
|
||||
expect(() => parseCliOptions([...base, '--jobs', '0'])).toThrow(FREE_JOBS_INVALID);
|
||||
expect(() => parseCliOptions([...base, '--jobs'])).toThrow(FREE_JOBS_INVALID);
|
||||
expect(() => parseCliOptions(['--jobs', '2'])).toThrow(`${FREE_JOBS_INVALID}: --jobs needs --ci-run`);
|
||||
expect(() => parseCliOptions(['--ci-plan', 'p.json', '--jobs', '2'])).toThrow(FREE_JOBS_INVALID);
|
||||
});
|
||||
|
||||
test('k may not exceed the shard file count; an empty shard still takes 1', () => {
|
||||
expect(() => validateJobsForShard(4, 4, 3)).not.toThrow();
|
||||
expect(() => validateJobsForShard(1, 0, 21)).not.toThrow();
|
||||
expect(() => validateJobsForShard(5, 4, 3)).toThrow(`${FREE_JOBS_INVALID}: --jobs 5 is more than CI shard 3's 4 file(s); use 1 to 4.`);
|
||||
expect(() => validateJobsForShard(2, 0, 21)).toThrow(FREE_JOBS_INVALID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('k = 1 golden: PR 2 changes nothing in CI', () => {
|
||||
test('one child over the whole planned shard with the same wall, one retry pass, and the same result keys', async () => {
|
||||
const h = harness(files);
|
||||
const { result, exitCode } = await h.run(1);
|
||||
expect(h.calls.map(({ files: f, shardNumber, totalShards, options }) => ({ f, shardNumber, totalShards, options }))).toEqual([
|
||||
{ f: h.shard.files, shardNumber: 1, totalShards: 1, options: { wallTimeoutMs: 360_000, verbose: false, testCounts: true } },
|
||||
]);
|
||||
expect(h.guards).toEqual([]);
|
||||
expect(h.retries).toEqual([[passed(h.shard.files)]]);
|
||||
expect(h.calls[0].options.wallTimeoutMs).toBe(360_000);
|
||||
expect(Object.keys(result)).toEqual(['planId', 'revision', 'outcome', 'retry']);
|
||||
expect(result).toEqual({ planId: h.plan.id, revision: 'revision-a', outcome: passed(h.shard.files), retry: null });
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('a k = 1 failure reaches the retry pass as the child reported it', async () => {
|
||||
const h = harness(files, runFiles => failed(runFiles, [runFiles[0]]));
|
||||
const { result, exitCode } = await h.run(1);
|
||||
expect(h.retries).toEqual([[failed(h.shard.files, [h.shard.files[0]])]]);
|
||||
expect(result.outcome).toBe(h.retries[0][0]);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sub-shard assignment', () => {
|
||||
const pack = (packFiles: string[], count: number) => Array.from({ length: count }, (_, i) => packFiles.filter((_, j) => j % count === i));
|
||||
|
||||
test('disjoint, identity-bound, covering the shard once, with exclusive files in one trailing serial sub-shard', () => {
|
||||
const shardFiles = [...files, EXCLUSIVE];
|
||||
const assignments = assignSubShards('plan-id-for-tests', { shard: 4, files: shardFiles }, 3, file => file in TREE_MUTATING, pack);
|
||||
expect(assignments.map(entry => [entry.id, entry.phase, entry.files.length])).toEqual([
|
||||
['plan-id-for-:4.1', 'parallel', 3], ['plan-id-for-:4.2', 'parallel', 2], ['plan-id-for-:4.3', 'parallel', 2], ['plan-id-for-:4.4', 'serial', 1],
|
||||
]);
|
||||
expect(assignments.at(-1)!.files).toEqual([EXCLUSIVE]);
|
||||
expect(assignments.flatMap(entry => entry.files).sort()).toEqual([...shardFiles].sort());
|
||||
});
|
||||
|
||||
test('k is clamped to the parallel file count and a shard of only exclusive files stays one serial child', () => {
|
||||
expect(assignSubShards('p', { shard: 1, files: files.slice(0, 2) }, 4, () => false, pack).length).toBe(2);
|
||||
expect(assignSubShards('p', { shard: 1, files: [EXCLUSIVE] }, 1, file => file in TREE_MUTATING, pack)
|
||||
.map(entry => entry.phase)).toEqual(['serial']);
|
||||
});
|
||||
|
||||
test('a packer that drops or repeats a file is rejected before any child starts', () => {
|
||||
expect(() => assignSubShards('p', { shard: 2, files }, 2, () => false, packFiles => [packFiles, [packFiles[0]]])).toThrow(/FREE_SUBSHARD_REJECTED/);
|
||||
expect(() => assignSubShards('p', { shard: 2, files }, 2, () => false, packFiles => [packFiles.slice(1)])).toThrow(/exactly once/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('k > 1: merge, rejection and one retry pass per CI shard', () => {
|
||||
test('parallel sub-shards share the concurrent home guard, then exclusive files run alone after they settle', async () => {
|
||||
const h = harness([...files, EXCLUSIVE]);
|
||||
const { result, exitCode } = await h.run(3);
|
||||
expect(exitCode).toBe(0);
|
||||
const parallel = h.calls.filter(call => !call.files.includes(EXCLUSIVE));
|
||||
const serial = h.calls.find(call => call.files.includes(EXCLUSIVE))!;
|
||||
expect(parallel).toHaveLength(3);
|
||||
expect(parallel.every(call => typeof call.options.homeGuard === 'function')).toBe(true);
|
||||
expect(serial.options.homeGuard).toBeUndefined();
|
||||
expect(serial.files).toEqual([EXCLUSIVE]);
|
||||
expect(serial.startedAt).toBeGreaterThanOrEqual(Math.max(...parallel.map(call => call.endedAt!)));
|
||||
expect(h.guards).toEqual([{ files: parallel.flatMap(call => call.files), shards: 3 }]);
|
||||
expect(h.calls.every(call => call.totalShards === 4)).toBe(true);
|
||||
expect(parallel.every(call => call.options.wallTimeoutMs === 360_000)).toBe(true);
|
||||
expect(serial.options.wallTimeoutMs).toBeLessThan(360_000);
|
||||
expect(serial.options.wallTimeoutMs).toBeGreaterThan(350_000);
|
||||
expect(result.jobs).toBe(3);
|
||||
expect(result.outcome).toMatchObject({ shard: 1, files: h.shard.files, status: 'passed', exitCode: 0, failingFiles: [], unattributedFailures: 0,
|
||||
summary: { testsRan: 16, filesRan: 8, sawTerminalSummary: true } });
|
||||
expect(result.subShards!.map(record => record.status)).toEqual(['passed', 'passed', 'passed', 'passed']);
|
||||
expect(h.retries).toHaveLength(1);
|
||||
expect(() => verifyFreeCiResults(h.plan, [result])).not.toThrow();
|
||||
});
|
||||
|
||||
test('failures from every sub-shard are collected first, so k never multiplies the five-file retry cap', async () => {
|
||||
const h = harness(files, runFiles => failed(runFiles, runFiles.slice(0, 2)));
|
||||
const { result, exitCode } = await h.run(3);
|
||||
expect(h.retries).toHaveLength(1);
|
||||
expect(h.retries[0]).toHaveLength(1);
|
||||
expect(result.outcome.failingFiles).toHaveLength(6);
|
||||
expect(eligibleFreeRetryFiles(h.retries[0])).toBeNull();
|
||||
expect(exitCode).toBe(1);
|
||||
const spread = harness(files, runFiles => failed(runFiles, runFiles.slice(0, 1)));
|
||||
await spread.run(3);
|
||||
expect(eligibleFreeRetryFiles(spread.retries[0])!.sort()).toEqual(spread.calls.map(call => call.files[0]).sort());
|
||||
});
|
||||
|
||||
test('a missing, duplicate or incomplete sub-shard result fails the CI shard and vetoes the retry', async () => {
|
||||
const errors = spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const cases: Array<(runFiles: string[]) => FreeShardOutcome | Promise<FreeShardOutcome>> = [
|
||||
runFiles => runFiles.includes(files[0]) ? Promise.reject(new Error('spawn failed')) : passed(runFiles),
|
||||
runFiles => passed(runFiles.includes(files[0]) ? [...runFiles, files[1]] : runFiles),
|
||||
runFiles => runFiles.includes(files[0]) ? { ...passed(runFiles), summary: { testsRan: 1, filesRan: runFiles.length - 1, sawTerminalSummary: true } } : passed(runFiles),
|
||||
runFiles => runFiles.includes(files[0]) ? { ...passed(runFiles), summary: undefined } : passed(runFiles),
|
||||
];
|
||||
for (const respond of cases) {
|
||||
errors.mockClear();
|
||||
const h = harness(files, respond);
|
||||
const { result, exitCode } = await h.run(3);
|
||||
expect(h.calls).toHaveLength(3);
|
||||
expect(result.outcome.status).toBe('failed');
|
||||
expect(result.outcome.unattributedFailures).toBeGreaterThan(0);
|
||||
expect(eligibleFreeRetryFiles([result.outcome])).toBeNull();
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errors.mock.calls.map(call => String(call[0])).some(line => line.startsWith('[test:free] FREE_SUBSHARD_REJECTED: CI shard 1: plan-id-for-:1.'))).toBe(true);
|
||||
}
|
||||
} finally { errors.mockRestore(); }
|
||||
});
|
||||
|
||||
test('a failed sub-shard that ran too few or too many files is rejected, so counts cannot cancel and no retry repairs it', async () => {
|
||||
const errors = spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const h = harness(files.slice(0, 4), runFiles => ({ ...failed(runFiles, [runFiles[0]]),
|
||||
summary: { testsRan: 2, filesRan: runFiles.includes(files[0]) ? 1 : 3, sawTerminalSummary: true } }));
|
||||
const { result } = await h.run(2);
|
||||
expect(result.outcome.summary!.filesRan).toBe(4);
|
||||
expect(result.outcome.unattributedFailures).toBeGreaterThanOrEqual(2);
|
||||
expect(eligibleFreeRetryFiles([result.outcome])).toBeNull();
|
||||
const forged: FreeCiResult = { ...result, outcome: { ...result.outcome, unattributedFailures: 0 },
|
||||
retry: { ...passed([files[0], files[1]]), shard: 2 } };
|
||||
expect(() => verifyFreeCiResults({ ...h.plan, revision: 'revision-a' } as never, [forged])).toThrow(/did not run every assigned file|identity/);
|
||||
expect(subShardRecordProblems(forged)).toContain('a sub-shard did not run every assigned file');
|
||||
} finally { errors.mockRestore(); }
|
||||
});
|
||||
|
||||
test('the serial sub-shard gets only what is left of the wall, and none left means it is not launched', async () => {
|
||||
const errors = spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const h = harness([...files, EXCLUSIVE], async runFiles => {
|
||||
if (!runFiles.includes(EXCLUSIVE)) await new Promise(resolve => setTimeout(resolve, 60));
|
||||
return passed(runFiles);
|
||||
});
|
||||
const { result } = await runCiShard({ plan: h.plan, shard: h.shard, revision: 'revision-a', jobs: 2, wallTimeoutMs: 50, verbose: false, context }, h.deps);
|
||||
expect(h.calls.some(call => call.files.includes(EXCLUSIVE))).toBe(false);
|
||||
expect(result.subShards!.at(-1)).toMatchObject({ status: 'missing', phase: 'serial' });
|
||||
expect(result.outcome.status).toBe('failed');
|
||||
} finally { errors.mockRestore(); }
|
||||
});
|
||||
|
||||
test('a real-home change in the concurrent phase and a cancelled serial phase both fail the shard', async () => {
|
||||
const errors = spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const home = harness(files);
|
||||
home.setHomeChange('real home changed while 3 shards ran concurrently');
|
||||
expect((await home.run(3)).result.outcome.status).toBe('failed');
|
||||
const cancelled = harness([...files, EXCLUSIVE]);
|
||||
cancelled.cancel();
|
||||
const { result } = await cancelled.run(2);
|
||||
expect(cancelled.calls.some(call => call.files.includes(EXCLUSIVE))).toBe(false);
|
||||
expect(result.subShards!.at(-1)).toMatchObject({ status: 'missing', files: [EXCLUSIVE] });
|
||||
expect(result.outcome.status).toBe('failed');
|
||||
} finally { errors.mockRestore(); }
|
||||
});
|
||||
|
||||
test('a sub-shard timeout prints FREE_SUBSHARD_TIMEOUT with the file, k, runner, run id and a serial reproduce', async () => {
|
||||
const errors = spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const h = harness(files, runFiles => runFiles.includes(files[3])
|
||||
? { ...failed(runFiles, []), status: 'timed-out', exitCode: null, inFlight: [files[3]] } : passed(runFiles));
|
||||
const { result, exitCode } = await h.run(3);
|
||||
expect(result.outcome.status).toBe('timed-out');
|
||||
expect(exitCode).toBe(1);
|
||||
const text = errors.mock.calls.map(call => String(call[0])).join('\n');
|
||||
expect(text).toContain('FREE_SUBSHARD_TIMEOUT: CI shard 1 sub-shard plan-id-for-:1.1 (3 files)');
|
||||
expect(text).toContain('hit its 360s wall at --jobs 3 on runner-7 (linux, 8 CPUs), run 123/attempt 2.');
|
||||
expect(text).toContain(`in flight: ${files[3]}`);
|
||||
expect(text).toContain(`printf '%s\\n' '${files[3]}' > files.txt && GSTACK_FREE_JOBS=1 bun run scripts/test-free-shards.ts --files-from files.txt`);
|
||||
expect(text).toContain("--ci-run 'plan.json' --shard 1 --result shard-1.json --jobs 1");
|
||||
expect(text).toContain('add it to SLOW_UNDER_LOAD in scripts/lib/shard-jobs.ts with run id 123/attempt 2; never raise the timeout.');
|
||||
} finally { errors.mockRestore(); }
|
||||
});
|
||||
|
||||
test('--ci-verify rejects tampered sub-shard records', () => {
|
||||
const plan = createFreeCiPlan(files, 1, durations, 'revision-a');
|
||||
const shard = plan.shards[0];
|
||||
const assignments = assignSubShards(plan.id, shard, 2, () => false, packFiles => [packFiles.slice(0, 3), packFiles.slice(3)]);
|
||||
const merged = mergeSubShardOutcomes(shard, assignments.map(assignment => ({ assignment, outcome: passed(assignment.files) })), 10);
|
||||
const result: FreeCiResult = { planId: plan.id, revision: 'revision-a', outcome: merged.outcome, retry: null, jobs: 2, subShards: merged.records };
|
||||
expect(() => verifyFreeCiResults(plan, [result])).not.toThrow();
|
||||
const renamed = structuredClone(result);
|
||||
renamed.subShards![0].id = 'other:1.1';
|
||||
expect(() => verifyFreeCiResults(plan, [renamed])).toThrow(/ids or phases/);
|
||||
const tooMany = structuredClone(result);
|
||||
tooMany.jobs = 1;
|
||||
expect(subShardRecordProblems({ ...tooMany, jobs: 2, subShards: [...tooMany.subShards!, { ...tooMany.subShards![0], id: `${plan.id.slice(0, 12)}:1.3`, files: [] }] }))
|
||||
.toContain('sub-shard ids or phases do not match the plan and --jobs');
|
||||
const overlap = structuredClone(result);
|
||||
overlap.subShards![1].files = [...overlap.subShards![1].files, overlap.subShards![0].files[0]];
|
||||
expect(() => verifyFreeCiResults(plan, [overlap])).toThrow(/sub-shards rejected/);
|
||||
const hidden = structuredClone(result);
|
||||
hidden.subShards![0].status = 'failed';
|
||||
expect(() => verifyFreeCiResults(plan, [hidden])).toThrow(/a passing shard holds a failed sub-shard/);
|
||||
expect(() => verifyFreeCiResults(plan, [{ ...result, jobs: 1 }])).toThrow(/valid --jobs/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-file test and expect() counts from bun JUnit', () => {
|
||||
const xml = (file: string) => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites name="bun test" tests="6" assertions="9" failures="1" skipped="2">
|
||||
<testsuite name="${file}" file="${file}" tests="3" assertions="9" failures="1" skipped="2">
|
||||
<testsuite name="grp" file="${file}" tests="1" assertions="5">
|
||||
<testcase name="x > y "q"" classname="grp" file="${file}" line="2" assertions="5" />
|
||||
</testsuite>
|
||||
<testcase name="todo" classname="" file="${file}" line="3" assertions="0"><skipped message="TODO" /></testcase>
|
||||
<testcase name="skip" classname="" file="${file}" line="4" assertions="0"><skipped /></testcase>
|
||||
<testcase name="fails" classname="" file="${file}" line="5" assertions="4"><failure type="AssertionError" message="a <b>">x</failure></testcase>
|
||||
</testsuite>
|
||||
</testsuites>`;
|
||||
|
||||
test('classifies every testcase and sums assertions per file; matches cwd-relative paths back to planned files', () => {
|
||||
const counts = parseJunitCounts(xml('../../test/a&b.test.ts'), ['test/a&b.test.ts'], 4);
|
||||
expect(counts).toEqual({ source: 'bun-junit', complete: true, files: { 'test/a&b.test.ts': { tests: 4, pass: 1, fail: 1, skip: 1, todo: 1, expects: 9 } },
|
||||
totals: { tests: 4, pass: 1, fail: 1, skip: 1, todo: 1, expects: 9 } });
|
||||
});
|
||||
|
||||
test('an ambiguous short path is never credited to one of two planned files that share it', () => {
|
||||
const counts = parseJunitCounts(xml('a.test.ts'), ['test/a.test.ts', 'browse/test/a.test.ts'], 4);
|
||||
expect(counts.complete).toBe(false);
|
||||
expect(counts.problem).toContain('unplanned');
|
||||
});
|
||||
|
||||
test('an absolute path is credited to the planned file under the root; an unplanned file never borrows a shorter planned path', () => {
|
||||
const planned = ['test/telemetry.test.ts', 'browse/test/telemetry.test.ts'];
|
||||
expect(Object.keys(parseJunitCounts(xml('/repo/browse/test/telemetry.test.ts'), planned, 4, '/repo').files)).toEqual(['browse/test/telemetry.test.ts']);
|
||||
expect(Object.keys(parseJunitCounts(xml('../../repo/test/telemetry.test.ts'), planned, 4, '/repo').files)).toEqual(['test/telemetry.test.ts']);
|
||||
const borrowed = parseJunitCounts(xml('browse/test/x.test.ts'), ['test/x.test.ts'], 4, '/repo');
|
||||
expect(borrowed).toMatchObject({ complete: false, problem: expect.stringContaining('browse/test/x.test.ts') });
|
||||
expect(parseJunitCounts(xml('/elsewhere/test/x.test.ts'), ['test/x.test.ts'], 4, '/repo').complete).toBe(false);
|
||||
});
|
||||
|
||||
test('the report is read only as a bounded regular file, never through a link or a FIFO', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-junit-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'real.xml'), '<testsuites/>');
|
||||
expect(readJunitReport(path.join(dir, 'real.xml'))).toBe('<testsuites/>');
|
||||
expect(readJunitReport(path.join(dir, 'absent.xml'))).toBeNull();
|
||||
if (process.platform !== 'win32') {
|
||||
fs.symlinkSync(path.join(dir, 'real.xml'), path.join(dir, 'link.xml'));
|
||||
expect(readJunitReport(path.join(dir, 'link.xml'))).toBeNull();
|
||||
const fifo = Bun.spawnSync(['mkfifo', path.join(dir, 'fifo.xml')], { timeout: 5_000 });
|
||||
if (fifo.exitCode === 0) expect(readJunitReport(path.join(dir, 'fifo.xml'))).toBeNull();
|
||||
}
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a missing report, an unplanned file or a count that disagrees with bun summary is incomplete, never silently complete', () => {
|
||||
expect(parseJunitCounts(null, ['test/a.test.ts'], 4)).toMatchObject({ complete: false, problem: 'bun wrote no JUnit report' });
|
||||
expect(parseJunitCounts(xml('test/z.test.ts'), ['test/a.test.ts'], 4)).toMatchObject({ complete: false, problem: expect.stringContaining('unplanned') });
|
||||
expect(parseJunitCounts(xml('test/a.test.ts'), ['test/a.test.ts'], 5)).toMatchObject({ complete: false, problem: "JUnit counted 4 tests, bun's summary 5" });
|
||||
expect(parseJunitCounts(xml('test/a.test.ts'), ['test/a.test.ts'], null).complete).toBe(false);
|
||||
});
|
||||
|
||||
test('real bun children: counts are identical per file at k = 1 and k = 2, add up to bun summary, and leave the stream unchanged', async () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-shard-jobs-')));
|
||||
try {
|
||||
const fixtures: Record<string, string> = {
|
||||
't/a.test.ts': "import { test, expect } from 'bun:test';\ntest('a1', () => { expect(1).toBe(1); expect(2).toBe(2); });\ntest('a2', () => { expect(3).toBe(3); });\ntest.skip('a3', () => {});\n",
|
||||
't/b.test.ts': "import { test, expect, describe } from 'bun:test';\ndescribe('g', () => { test('b1', () => { for (let i = 0; i < 5; i++) expect(i).toBe(i); }); });\ntest.todo('b2');\n",
|
||||
't/c.test.ts': "import { test, expect } from 'bun:test';\ntest('c1', () => { expect([1]).toEqual([1]); });\n",
|
||||
't/d.test.ts': "import { test, expect } from 'bun:test';\ntest.each([1, 2, 3])('d%p', n => { expect(n).toBeGreaterThan(0); expect(n).toBeLessThan(4); });\n",
|
||||
};
|
||||
for (const [file, body] of Object.entries(fixtures)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, file), body);
|
||||
}
|
||||
const planned = Object.keys(fixtures);
|
||||
const plan = createFreeCiPlan(planned, 1, {}, 'rev');
|
||||
const pids: number[] = [];
|
||||
const deps = (logs: string[]): CiShardDeps => ({
|
||||
runShard: async (runFiles, n, total, options) => {
|
||||
const logFilePath = path.join(root, `stream-${logs.length}-${runFiles.length}-${n}-${total}.log`);
|
||||
logs.push(logFilePath);
|
||||
const outcome = await runFreeShard(runFiles, n, total, { ...options, rootDir: root, quiet: true, log: () => {}, logFilePath });
|
||||
if (outcome.groupPid) pids.push(outcome.groupPid);
|
||||
return outcome;
|
||||
},
|
||||
retry: async outcomes => ({ exitCode: outcomes[0].status === 'passed' ? 0 : 1, retry: null }),
|
||||
pack: (packFiles, count) => Array.from({ length: count }, (_, i) => packFiles.filter((_, j) => j % count === i)),
|
||||
isSerial: () => false,
|
||||
terminationRequested: () => false,
|
||||
log: () => {},
|
||||
});
|
||||
const input = { plan, shard: plan.shards[0], revision: 'rev', wallTimeoutMs: 60_000, verbose: false, context };
|
||||
const serialLogs: string[] = [];
|
||||
const one = await runCiShard({ ...input, jobs: 1 }, deps(serialLogs));
|
||||
const two = await runCiShard({ ...input, jobs: 2 }, deps([]));
|
||||
expect([one.exitCode, two.exitCode]).toEqual([0, 0]);
|
||||
const expected = {
|
||||
't/a.test.ts': { tests: 3, pass: 2, fail: 0, skip: 1, todo: 0, expects: 3 },
|
||||
't/b.test.ts': { tests: 2, pass: 1, fail: 0, skip: 0, todo: 1, expects: 5 },
|
||||
't/c.test.ts': { tests: 1, pass: 1, fail: 0, skip: 0, todo: 0, expects: 1 },
|
||||
't/d.test.ts': { tests: 3, pass: 3, fail: 0, skip: 0, todo: 0, expects: 6 },
|
||||
};
|
||||
expect(one.result.outcome.counts).toMatchObject({ complete: true, files: expected, totals: { tests: 9, expects: 15 } });
|
||||
expect(two.result.outcome.counts).toEqual(one.result.outcome.counts);
|
||||
expect(two.result.subShards).toHaveLength(2);
|
||||
expect(one.result.outcome.summary).toEqual(two.result.outcome.summary);
|
||||
const bare = await runFreeShard(planned, 1, 1, { rootDir: root, quiet: true, log: () => {}, logFilePath: path.join(root, 'bare.log') });
|
||||
expect(bare.counts).toBeUndefined();
|
||||
const stream = (file: string) => fs.readFileSync(file, 'utf8').replace(/ \[[\d.]+m?s\]/g, '').split('\n').filter(line => !line.startsWith('[test:free]'));
|
||||
expect(stream(serialLogs[0])).toEqual(stream(path.join(root, 'bare.log')));
|
||||
for (const pid of pids) expect(() => process.kill(pid, 0)).toThrow();
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
|
||||
test('a hung sub-shard is killed at its wall while its sibling finishes, and no child outlives the parent', async () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-shard-jobs-')));
|
||||
const errors = spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const planned = ['t/ok.test.ts', 't/hang.test.ts'];
|
||||
for (const file of planned) {
|
||||
fs.mkdirSync(path.join(root, 't'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, file), "import { test, expect } from 'bun:test';\ntest('ok', () => { expect(1).toBe(1); });\n");
|
||||
}
|
||||
const plan = createFreeCiPlan(planned, 1, {}, 'rev');
|
||||
const outcomes: FreeShardOutcome[] = [];
|
||||
const startedAt = performance.now();
|
||||
const { result } = await runCiShard({ plan, shard: plan.shards[0], revision: 'rev', jobs: 2, wallTimeoutMs: 2_000, verbose: false, context }, {
|
||||
runShard: async (runFiles, n, total, options) => {
|
||||
const outcome = await runFreeShard(runFiles, n, total, { ...options, rootDir: root, quiet: true, log: () => {},
|
||||
...(runFiles.includes('t/hang.test.ts') ? { commandFor: () => ({ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] }) } : {}) });
|
||||
outcomes.push(outcome);
|
||||
return outcome;
|
||||
},
|
||||
retry: async () => ({ exitCode: 1, retry: null }),
|
||||
pack: packFiles => packFiles.map(file => [file]),
|
||||
isSerial: () => false,
|
||||
terminationRequested: () => false,
|
||||
log: () => {},
|
||||
});
|
||||
expect(performance.now() - startedAt).toBeLessThan(20_000);
|
||||
expect(result.outcome.status).toBe('timed-out');
|
||||
expect(result.subShards!.map(record => [record.files[0], record.status]).sort()).toEqual([['t/hang.test.ts', 'timed-out'], ['t/ok.test.ts', 'passed']]);
|
||||
for (const outcome of outcomes) expect(() => process.kill(outcome.groupPid!, 0)).toThrow();
|
||||
expect(errors.mock.calls.some(call => String(call[0]).includes('FREE_SUBSHARD_TIMEOUT'))).toBe(true);
|
||||
} finally { errors.mockRestore(); fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('serial lists and the duration seed', () => {
|
||||
test('SLOW_UNDER_LOAD entries name live free files, a reason and a CI run id, and never overlap TREE_MUTATING', () => {
|
||||
const census = new Set(collectFreeTestFiles());
|
||||
for (const [file, entry] of Object.entries(SLOW_UNDER_LOAD)) {
|
||||
expect(census.has(file), `SLOW_UNDER_LOAD names ${file}, which is not a free test file`).toBe(true);
|
||||
expect(file in TREE_MUTATING).toBe(false);
|
||||
expect(entry.reason.trim()).not.toBe('');
|
||||
expect(entry.runId).toMatch(/^\d+(\/attempt \d+)?$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('seeds take only one-file children; sub-shard runs have no path to the seed writer', () => {
|
||||
expect(() => assertSoloSeedSample('test/a.test.ts', { files: ['test/a.test.ts'] })).not.toThrow();
|
||||
expect(() => assertSoloSeedSample('test/a.test.ts', { files: ['test/a.test.ts', 'test/b.test.ts'] })).toThrow(/one-file serial children/);
|
||||
expect(() => assertSoloSeedSample('test/a.test.ts', { files: ['test/a.test.ts'], summary: { testsRan: 9, filesRan: 2, sawTerminalSummary: true } })).toThrow(/one-file serial children/);
|
||||
expect(fs.readFileSync(path.join(ROOT, 'scripts/lib/shard-jobs.ts'), 'utf8')).not.toContain('writeDurationSeed');
|
||||
const runner = fs.readFileSync(path.join(ROOT, 'scripts/test-free-shards.ts'), 'utf8');
|
||||
const recorder = runner.slice(runner.indexOf('async function recordFreeTestDurations('), runner.indexOf('async function retryFailedFreeFiles('));
|
||||
expect(runner.split('writeDurationSeed(').length - 1).toBe(recorder.split('writeDurationSeed(').length - 1);
|
||||
expect(recorder).toContain('assertSoloSeedSample(file, outcome)');
|
||||
});
|
||||
|
||||
test('the run context names the CI run and attempt, else local', () => {
|
||||
expect(ciRunContext('p.json', { GITHUB_RUN_ID: '42', GITHUB_RUN_ATTEMPT: '3', RUNNER_NAME: 'ubi-1' })).toMatchObject({ runId: '42/attempt 3', runner: expect.stringMatching(/^ubi-1 \(/) });
|
||||
expect(ciRunContext('p.json', {}).runId).toBe('local');
|
||||
expect(subShardTimeoutText(2, 4, { id: 'x:2.1', phase: 'parallel', files: ['test/a.test.ts'], status: 'timed-out', exitCode: null, elapsedMs: 1, failingFiles: [], unattributedFailures: 0 },
|
||||
[], 10_000, context)[1]).toContain('in flight: unknown');
|
||||
const serial = { id: 'x:2.5', phase: 'serial' as const, files: ['test/s.test.ts'], status: 'timed-out' as const, exitCode: null, elapsedMs: 1, failingFiles: [], unattributedFailures: 0 };
|
||||
const squeezed = subShardTimeoutText(2, 4, serial, [], 4_000, context, 360_000);
|
||||
expect(squeezed[0]).toContain('hit its 4s wall');
|
||||
expect(squeezed[4]).toContain('left this serial sub-shard 4s of the 360s shard wall');
|
||||
expect(subShardTimeoutText(2, 4, serial, [], 360_000, context, 360_000)[4]).toContain('already runs alone');
|
||||
});
|
||||
});
|
||||
@@ -219,16 +219,37 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST
|
||||
expect(flat(read('ship/sections/budget-handoff.md.tmpl'))).toContain('the wait until the last settles (`done`, `failed` or a final summary; a fresh `idle` has not run)');
|
||||
});
|
||||
|
||||
// Value: protects=audits that cannot block (Steps 7, 8, 9.2.1, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased;
|
||||
// Value: protects=audits that cannot block (Steps 7, 8, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased;
|
||||
// why_new=audit model routing is new; seam=none; no_claim=the audits' recall
|
||||
test('audit readers run on a small model at low effort, named for Capy, and Phase 3 dispatches only what READER names', () => {
|
||||
expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body');
|
||||
expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body');
|
||||
expect(flat(doc)).toContain('`Coverage:` and Step 8\'s line come from `--audit` when no reader ran');
|
||||
const capy = read('capy/skill/gstack/SKILL.md');
|
||||
expect(capy).toContain('model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }');
|
||||
expect(capy).toContain('`anthropic/claude-haiku-5-5` also fits');
|
||||
});
|
||||
|
||||
// Value: protects=Step 9.2.1's report-only probes run in the parent's own Phase 4 program, as the section says, with its Reads in Phase 3, and
|
||||
// their result line reaches the record, at no extra call; fails_when=the driver or the Capy skill sends Step 9.2.1 out as a QA reader
|
||||
// (C4-36, C5-36: it refused both passes, "parent-only", and QA coverage was empty), the probes leave the record, or they take their own call;
|
||||
// why_new=#3648 made Step 9.2.1 a reader (GST-450); seam=none; no_claim=the probes' recall
|
||||
test('Step 9.2.1 is the parent\'s: Reads in Phase 3, probes in Phase 4\'s one program, Exploratory QA in the record, never an extra', () => {
|
||||
const f = flat(doc);
|
||||
const phase = (n: number) => f.slice(f.indexOf(`## ${n}. `), f.indexOf(`## ${n + 1}. `));
|
||||
expect(phase(3)).not.toMatch(/QA reader/);
|
||||
expect(phase(3)).toContain("9.2.1's Reads only");
|
||||
expect(phase(4)).toContain("Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`)");
|
||||
const at = ['Step 8.1', "9.2.1's probes", '8.2 (`Scope Check:`)', 'Steps 9.3, 9.4 and the repeat decision'].map(s => phase(4).indexOf(s));
|
||||
expect(at[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(at).toEqual([...at].sort((a, b) => a - b));
|
||||
expect(phase(4)).toContain('one program');
|
||||
expect(f).toContain('the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions**');
|
||||
const capy = flat(read('capy/skill/gstack/SKILL.md'));
|
||||
expect(capy).toContain('Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`');
|
||||
expect(capy).not.toMatch(/9\.2\.1[^.]*`extras`/);
|
||||
expect(flat(read('ship/sections/review-army.md'))).toContain('### Step 9.2.1: Exploratory QA (before Fix-First) Only the parent runs report-only discovery.');
|
||||
});
|
||||
|
||||
// Value: protects=a driven /ship dispatches every blocking reader on its rendered brief file unchanged, checks it with `sent`, merges findings only through `collect`, gates judgment with `judge` and verifies the run before the bind; fails_when=the driver lets the model write or trim a brief again or merge findings in prose (round 4b: arm D lost 8 blockers with 75 of 112 briefs paraphrased);
|
||||
// why_new=the driver named no renderer or hash check; seam=none; no_claim=the parent's adherence, which verify checks after the fact
|
||||
test('briefs go out as rendered and findings come back unchanged: render, sent, collect, judge, verify', () => {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* The shared land lock (lib/ship-queue-land-lock.ts, #3599): `land` and `train land` take the same
|
||||
* refs/notes/gstack-land lock, so a standalone land and a train slot can never both publish onto one main.
|
||||
* Module cases run against a bare origin and two clones (the race, the read states, owner kinds, the
|
||||
* --break-lock rules); command cases drive bin/gstack-ship-queue against the train fixture and its gh
|
||||
* shims (both directions, the train-scoped between phase, refusal and resume, the freeze-owned lock and the
|
||||
* repair train's takeover).
|
||||
*/
|
||||
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 { acquireLandLock, LAND_LOCK_REF, readLock, writeLock, type LandLock, type LockOwner } from '../lib/ship-queue-land-lock';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import {
|
||||
GREEN_GATE, TRAIN_POLICY, fragmentPr, nonceOf, queue, repoKey, trainFixture, trainJson, wrapLand, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixtures: TrainFixture[] = [];
|
||||
afterEach(() => {
|
||||
while (repos.length) cleanup(repos.pop()!);
|
||||
while (fixtures.length) cleanup(fixtures.pop()!.repo);
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-31T12:00:00Z');
|
||||
const at = (min: number) => () => new Date(T0 + min * 60_000);
|
||||
|
||||
/** Run fn with the fixture's git identity in process.env (the lock writes commits in-process). */
|
||||
function withRepoEnv<T>(repo: FixtureRepo, fn: () => T): T {
|
||||
const saved = Object.fromEntries(Object.keys(repo.env).map(k => [k, process.env[k]]));
|
||||
Object.assign(process.env, repo.env);
|
||||
try { return fn(); } finally {
|
||||
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
}
|
||||
}
|
||||
function bareRepo(): FixtureRepo { const r = makeFixtureRepo({ policy: BASE_POLICY, prefix: 'gstack-land-lock-' }); repos.push(r); return r; }
|
||||
const standalone = (pr: string): LockOwner => ({ pr });
|
||||
const slotOf = (train: string, slot: number, pr: string): LockOwner => ({ train, slot, pr });
|
||||
function take(root: string, owner: LockOwner, o: { journal?: string; now?: () => Date; breakLock?: boolean; breakTrainLock?: boolean; liveTrain?: (nonce: string) => boolean; phase?: LandLock['phase'] } = {}) {
|
||||
return acquireLandLock({
|
||||
root, holder: 'test:1', owner, journal: o.journal ?? ('train' in owner && owner.train ? `train/${owner.train}/slot-${owner.slot}` : `land/${owner.pr}`),
|
||||
phase: () => o.phase ?? 'stamping', now: o.now ?? at(0), breakLock: o.breakLock, breakTrainLock: o.breakTrainLock, liveTrain: o.liveTrain,
|
||||
});
|
||||
}
|
||||
const failureOf = (r: ReturnType<typeof take>) => ('failure' in r ? r.failure : null);
|
||||
|
||||
describe('the lock module', () => {
|
||||
test('two writers racing the same lock sha leave one winner', () => {
|
||||
const repo = bareRepo();
|
||||
const other = coordinatorClone(repo);
|
||||
withRepoEnv(repo, () => {
|
||||
const lock = (pr: string): LandLock => ({ pr, holder: `h${pr}`, started_at: new Date(T0).toISOString(), phase: 'stamping', journal: `land/${pr}` });
|
||||
expect(readLock(repo.work).state).toBe('absent');
|
||||
const first = writeLock(repo.work, '', lock('1'));
|
||||
const second = writeLock(other, '', lock('2'));
|
||||
expect([first.ok, second.ok]).toEqual([true, false]);
|
||||
const seen = readLock(other);
|
||||
expect(seen.state === 'held' && seen.lock.pr).toBe('1');
|
||||
const a = writeLock(repo.work, seen.sha, lock('3'));
|
||||
const b = writeLock(other, seen.sha, lock('4'));
|
||||
expect([a.ok, b.ok]).toEqual([true, false]);
|
||||
const final = readLock(other);
|
||||
expect(final.state === 'held' && final.lock.pr).toBe('3');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('absent, released, held and unreadable are distinct; an unreadable lock refuses, and --break-lock replaces it', () => {
|
||||
const repo = bareRepo();
|
||||
withRepoEnv(repo, () => {
|
||||
const root = repo.work;
|
||||
expect(readLock(root)).toMatchObject({ state: 'absent' });
|
||||
const got = take(root, standalone('5'));
|
||||
expect(failureOf(got)).toBeNull();
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '5', phase: 'stamping' } });
|
||||
if ('held' in got) expect(got.held.release()).toBe(true);
|
||||
expect(readLock(root)).toMatchObject({ state: 'released' });
|
||||
const junk = repo.git('commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'not a lock');
|
||||
repo.git('push', '-q', '-f', 'origin', `${junk}:${LAND_LOCK_REF}`);
|
||||
expect(readLock(root)).toMatchObject({ state: 'unreadable' });
|
||||
const refused = take(root, standalone('6'));
|
||||
expect(failureOf(refused)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
|
||||
expect(failureOf(refused)!.message).toContain('cannot be read');
|
||||
const broke = take(root, standalone('6'), { breakLock: true });
|
||||
expect(failureOf(broke)).toBeNull();
|
||||
expect(broke.lines.join('\n')).toContain(`LOCK: broke an unreadable lock at ${junk.slice(0, 12)}`);
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '6' } });
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('ownership compares the owner kind and journal: the same PR across commands and across trains refuses; a resume of the same journal and a sequential acquisition succeed', () => {
|
||||
const repo = bareRepo();
|
||||
withRepoEnv(repo, () => {
|
||||
const root = repo.work;
|
||||
const mine = take(root, standalone('7'), { phase: 'waiting' });
|
||||
expect(failureOf(mine)).toBeNull();
|
||||
const asSlot = take(root, slotOf('trainA', 1, '7'));
|
||||
expect(failureOf(asSlot)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
|
||||
expect(failureOf(asSlot)!.message).toMatch(/^#7 holds the land lock \(test:1, phase waiting, since /);
|
||||
const resumed = take(root, standalone('7'), { now: at(3) });
|
||||
expect(failureOf(resumed)).toBeNull();
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '7', started_at: new Date(T0).toISOString() } });
|
||||
if ('held' in resumed) resumed.held.release();
|
||||
|
||||
const trainA = take(root, slotOf('trainA', 1, '7'), { phase: 'pushing' });
|
||||
expect(failureOf(trainA)).toBeNull();
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { train: 'trainA', slot: 1, pr: '7' } });
|
||||
const trainB = take(root, slotOf('trainB', 1, '7'));
|
||||
expect(failureOf(trainB)!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
|
||||
expect(failureOf(take(root, standalone('7')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
|
||||
expect(failureOf(take(root, slotOf('trainA', 2, '8')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
|
||||
const same = take(root, slotOf('trainA', 1, '7'));
|
||||
expect(failureOf(same)).toBeNull();
|
||||
if ('held' in same) same.held.release();
|
||||
expect(failureOf(take(root, standalone('8')))).toBeNull();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('--break-lock prints the holder, refuses a live train run\'s lock without --break-train-lock, and keeps the staleness, mid-push, between and freeze rules', () => {
|
||||
const repo = bareRepo();
|
||||
withRepoEnv(repo, () => {
|
||||
const root = repo.work;
|
||||
const hold = (owner: LockOwner, phase: LandLock['phase']) => {
|
||||
const r = readLock(root);
|
||||
if (r.state === 'held') writeLock(root, r.sha, { released: true, by: 'test', at: new Date(T0).toISOString() });
|
||||
expect(failureOf(take(root, owner, { phase }))).toBeNull();
|
||||
};
|
||||
hold(slotOf('trainA', 2, '9'), 'waiting');
|
||||
const live = take(root, standalone('3'), { breakLock: true, now: at(1) });
|
||||
expect(failureOf(live)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
|
||||
expect(failureOf(live)!.message).toContain('--break-train-lock');
|
||||
expect(live.lines).toContain(`LOCK_HOLDER: train trainA slot 2 (#9) holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
|
||||
const ended = take(root, standalone('3'), { breakLock: true, now: at(1), liveTrain: () => false });
|
||||
expect(failureOf(ended)).toBeNull();
|
||||
expect(ended.lines).toContain("LOCK: broke train trainA slot 2 (#9)'s lock (test:1, phase waiting)");
|
||||
|
||||
hold(slotOf('trainA', 2, '9'), 'waiting');
|
||||
const forced = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
|
||||
expect(failureOf(forced)).toBeNull();
|
||||
|
||||
hold(slotOf('trainA', 1, '9'), 'between');
|
||||
const young = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
|
||||
expect(failureOf(young)!.message).toMatch(/train trainA slot 1 \(#9\) is between slots \(60 s ago\); --break-lock refused/);
|
||||
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(11) }))).toBeNull();
|
||||
|
||||
hold(slotOf('trainA', 1, '9'), 'pushing');
|
||||
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) }))!.message).toMatch(/train trainA slot 1 \(#9\)'s holder is mid-push \(60 s ago\); --break-lock refused/);
|
||||
|
||||
hold(slotOf('trainA', 1, '9'), 'freeze');
|
||||
const frozen = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(60) });
|
||||
expect(failureOf(frozen)).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
|
||||
expect(failureOf(take(root, slotOf('trainB', 1, '4'), { breakLock: true, breakTrainLock: true, now: at(60) }))).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
|
||||
|
||||
hold(standalone('5'), 'waiting');
|
||||
const plain = take(root, standalone('3'), { breakLock: true, now: at(1) });
|
||||
expect(plain.lines).toContain(`LOCK_HOLDER: #5 holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
|
||||
expect(plain.lines).toContain("LOCK: broke #5's lock (test:1, phase waiting)");
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
function stampedTrain(prs = ['1', '2']) {
|
||||
const fx = trainFixture(POLICY, 'gstack-land-lock-'); fixtures.push(fx); wrapLand(fx);
|
||||
const heads = prs.map(pr => fragmentPr(fx, pr));
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', prs.map((pr, i) => `${pr}@${heads[i]}`).join(',')]).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
return { fx, heads, nonce };
|
||||
}
|
||||
const trainLand = (fx: TrainFixture, nonce: string, pr: string, extra: string[] = []) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait', ...extra]);
|
||||
const land = (fx: TrainFixture, pr: string, extra: string[] = []) => queue(fx, ['land', '--pr', pr, '--no-wait', '--allow-repo-commands', ...extra]);
|
||||
const originRev = (fx: TrainFixture, ref: string) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', '--verify', '-q', ref);
|
||||
/** The lock as origin holds it: the parsed lock.json, or null when origin has no lock ref. */
|
||||
function originLock(fx: TrainFixture): any {
|
||||
const sha = (() => { try { return originRev(fx, LAND_LOCK_REF); } catch { return ''; } })();
|
||||
return sha ? JSON.parse(fx.repo.git('--git-dir', fx.repo.origin, 'show', `${sha}:lock.json`)) : null;
|
||||
}
|
||||
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze');
|
||||
|
||||
describe('land and train land share the lock', () => {
|
||||
test('a train slot holding the lock refuses a standalone land (no takeover, no freeze; --break-lock names the train and wants --break-train-lock); the train keeps it between slots and releases it after the last', () => {
|
||||
const { fx, heads, nonce } = stampedTrain();
|
||||
const pin3 = fragmentPr(fx, '3');
|
||||
const pushed = trainLand(fx, nonce, '1');
|
||||
expect(pushed.out, pushed.err).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
|
||||
const lockSha = originRev(fx, LAND_LOCK_REF);
|
||||
|
||||
const blocked = land(fx, '3');
|
||||
expect(blocked.status).toBe(EXIT.refused);
|
||||
expect(blocked.err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase waiting.*\\(QUEUE_LAND_LOCKED\\)`));
|
||||
const broke = land(fx, '3', ['--break-lock']);
|
||||
expect(broke.status).toBe(EXIT.refused);
|
||||
expect(broke.err).toContain('--break-train-lock');
|
||||
expect(broke.out).toMatch(new RegExp(`^LOCK_HOLDER: train ${nonce} slot 1 \\(#1\\) holder=\\S+ phase=waiting since=`, 'm'));
|
||||
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
|
||||
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
|
||||
expect(fs.existsSync(freezeFile(fx))).toBe(false);
|
||||
|
||||
const merged = trainLand(fx, nonce, '1');
|
||||
expect(merged.status, merged.err).toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'between' });
|
||||
expect(land(fx, '3').err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase between.*\\(QUEUE_LAND_LOCKED\\)`));
|
||||
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
|
||||
|
||||
const second = trainLand(fx, nonce, '2');
|
||||
expect(second.out, second.err).toMatch(/^LOCK: train \S+ slot 2 takes over its train's between-slots lock/m);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 2, pr: '2', phase: 'waiting' });
|
||||
expect(trainLand(fx, nonce, '2').status).toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
expect(trainJson(fx, nonce).state).toBe('merged');
|
||||
expect(land(fx, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
void heads;
|
||||
}, 300_000);
|
||||
|
||||
// fails_when=settleLock releases the lock on a refusal partway through a train, so one transient gh or fetch failure on slot 2 lets a standalone land publish onto S1 and the rerun freezes the queue (red team, PR #3737 pass 1); why_new=the test above refuses slot 1 only, before anything merged; seam=the fixture's gh pr view; no_claim=the freeze itself
|
||||
test('a refusal partway through a train (slot 1 merged, slot 2 cannot read its PR) keeps the lock between slots, so a standalone land still refuses', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
fragmentPr(fx, '3');
|
||||
trainLand(fx, nonce, '1');
|
||||
expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok);
|
||||
const row = fx.read().prs['2'];
|
||||
fx.write(d => { delete d.prs['2']; });
|
||||
const refused = trainLand(fx, nonce, '2');
|
||||
expect(refused.err).toContain('(QUEUE_PROOF_MISSING)');
|
||||
expect(refused.out).toMatch(/^LOCK: between /m);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'between' });
|
||||
expect(land(fx, '3').err).toMatch(/holds the land lock .*phase between.*\(QUEUE_LAND_LOCKED\)/);
|
||||
fx.write(d => { d.prs['2'] = row; });
|
||||
expect(trainLand(fx, nonce, '2').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
}, 300_000);
|
||||
|
||||
/** Another train's lock, written to origin as that train's run would have left it. */
|
||||
function seedLock(fx: TrainFixture, lock: Partial<LandLock> & { train: string; phase: LandLock['phase'] }): void {
|
||||
withRepoEnv(fx.repo, () => {
|
||||
const cur = readLock(fx.repo.work);
|
||||
expect(writeLock(fx.repo.work, cur.sha, { pr: '9', slot: 1, holder: 'old:1', started_at: '2026-10-31T12:00:00.000Z', phase_at: '2026-10-31T12:00:00.000Z', journal: `train/${lock.train}/slot-1`, ...lock } as LandLock).ok).toBe(true);
|
||||
});
|
||||
}
|
||||
/** A freeze record for this repository, cleared at `cleared`, written to the state root as the clearing verify leaves it. */
|
||||
function clearedFreeze(fx: TrainFixture, train: string, cleared: string): void {
|
||||
const file = path.join(freezeFile(fx), `${repoKey(fx)}.json`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify({ schema: 1, kind: 'freeze', repo_key: repoKey(fx), train, merged: ['9'], unmerged: [], main: { sha: '0'.repeat(40), tree: '0'.repeat(40) }, reason: 'test', backstop: null, repair: null, created_at: '2026-10-31T11:00:00.000Z', cleared_at: cleared, cleared_by: 'test', updated_at: cleared }));
|
||||
}
|
||||
|
||||
// fails_when=trainTakeover reads "no active freeze record here" as "the freeze is cleared", so a train whose notes fetch failed, or whose freezer never pushed the record, takes the freeze lock and lands onto a frozen main (red team, PR #3737 pass 2); why_new=the freeze test above only exercises the admitted repair train's takeover; seam=a lock and a freeze record written straight to origin and the state root; no_claim=how the record went missing
|
||||
test('another train\'s freeze lock is taken over only on a freeze record cleared after the lock: no record, or one cleared before it, refuses', () => {
|
||||
const { fx, nonce } = stampedTrain(['1']);
|
||||
const old = 'gate.train-old';
|
||||
seedLock(fx, { train: old, phase: 'freeze' });
|
||||
const noRecord = trainLand(fx, nonce, '1');
|
||||
expect(noRecord.status).toBe(EXIT.refused);
|
||||
expect(noRecord.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/);
|
||||
expect(noRecord.out).not.toMatch(/^PUSHED:/m);
|
||||
expect(originLock(fx)).toMatchObject({ train: old, phase: 'freeze' });
|
||||
|
||||
clearedFreeze(fx, old, '2026-10-31T11:30:00.000Z');
|
||||
expect(trainLand(fx, nonce, '1').err).toContain('(QUEUE_LAND_LOCKED)');
|
||||
clearedFreeze(fx, old, '2026-10-31T12:30:00.000Z');
|
||||
const taken = trainLand(fx, nonce, '1');
|
||||
expect(taken.out, taken.err).toContain(`LOCK: train ${nonce} takes over train ${old} slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z`);
|
||||
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
}, 300_000);
|
||||
|
||||
// fails_when=a freeze lock whose release failed after its freeze cleared refuses every standalone land for good, or a lock ref deleted on origin still reads held from this clone's cached copy (adversarial review, PR #3737 Step 11); why_new=the tests above take the freeze lock over only from train land; seam=a lock and a freeze record written straight to origin and the state root; no_claim=why the release failed
|
||||
test('a standalone land takes over a freeze lock once a fetched freeze record shows it cleared after the lock, and a lock deleted on origin reads as absent', () => {
|
||||
const { fx } = stampedTrain(['1']);
|
||||
fragmentPr(fx, '3');
|
||||
seedLock(fx, { train: 'gate.train-old', phase: 'freeze' });
|
||||
const frozen = land(fx, '3');
|
||||
expect(frozen.status).toBe(EXIT.refused);
|
||||
expect(frozen.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/);
|
||||
clearedFreeze(fx, 'gate.train-old', '2026-10-31T12:30:00.000Z');
|
||||
const taken = land(fx, '3');
|
||||
expect(taken.out, taken.err).toContain("LOCK: taking over train gate.train-old slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z");
|
||||
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
|
||||
const { fx: other } = stampedTrain(['1']);
|
||||
fragmentPr(other, '3');
|
||||
seedLock(other, { train: 'gate.train-old', phase: 'waiting' });
|
||||
expect(land(other, '3').err).toContain('(QUEUE_LAND_LOCKED)');
|
||||
other.repo.git('--git-dir', other.repo.origin, 'update-ref', '-d', LAND_LOCK_REF);
|
||||
expect(land(other, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
}, 300_000);
|
||||
|
||||
// fails_when=trainTakeover treats a live train as terminal (two landers publish onto one main) or never takes over a finished train's lock (a stale lock blocks every later train); why_new=no other test seats a foreign train's lock; seam=a second train record written into the state root; no_claim=how that train ended
|
||||
test('another train\'s lock is taken over once that train\'s record is terminal, and refused while it is still merging', () => {
|
||||
const { fx, nonce } = stampedTrain(['1']);
|
||||
const other = `${nonce}-other`;
|
||||
const put = (state: string) => {
|
||||
const dir = path.join(fx.state, 'ship-queue', 'trains', other);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'train.json'), JSON.stringify({ ...trainJson(fx, nonce), nonce: other, state }));
|
||||
};
|
||||
put('merging');
|
||||
seedLock(fx, { train: other, phase: 'waiting' });
|
||||
expect(trainLand(fx, nonce, '1').err).toMatch(/holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/);
|
||||
put('merged');
|
||||
const taken = trainLand(fx, nonce, '1');
|
||||
expect(taken.out, taken.err).toContain(`takes over train ${other} slot 1 (#9)'s lock; that train is merged`);
|
||||
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
}, 300_000);
|
||||
|
||||
test('a standalone land holding the lock refuses train land; a refusal before any push releases the train\'s lock', () => {
|
||||
const { fx, heads, nonce } = stampedTrain();
|
||||
fragmentPr(fx, '3');
|
||||
expect(land(fx, '3').out).toMatch(/^PUSHED: /m);
|
||||
const blocked = trainLand(fx, nonce, '1');
|
||||
expect(blocked.status).toBe(EXIT.refused);
|
||||
expect(blocked.err).toMatch(/#3 holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/);
|
||||
expect(blocked.out).not.toMatch(/^PUSHED:/m);
|
||||
expect(originRev(fx, 'refs/heads/pr-1')).toBe(heads[0]);
|
||||
|
||||
expect(land(fx, '3').out).toContain('LOCK: released');
|
||||
const stale = trainLand(fx, nonce, '1');
|
||||
expect(stale.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
}, 300_000);
|
||||
|
||||
test('after a push the train keeps its lock through a failed wait and a resume takes it back', () => {
|
||||
const { fx, nonce } = stampedTrain(['1']);
|
||||
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(trainLand(fx, nonce, '1').out)![1]!;
|
||||
fx.write(d => { d.ci[stamp] = [{ name: 'free-tests', status: 'completed', conclusion: 'failure' }] as any; });
|
||||
const red = trainLand(fx, nonce, '1');
|
||||
expect(red.status).not.toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
|
||||
fx.write(d => { delete d.ci[stamp]; });
|
||||
const resumed = trainLand(fx, nonce, '1');
|
||||
expect(resumed.status, resumed.err).toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
}, 300_000);
|
||||
|
||||
test('a stop that freezes leaves the lock in a freeze phase that --break-lock never breaks; only the admitted repair train takes it over, and it is released once the freeze clears', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
trainLand(fx, nonce, '1');
|
||||
expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok);
|
||||
trainLand(fx, nonce, '2');
|
||||
fx.write(d => { (d as any).outsideOnMerge = true; });
|
||||
const drift = trainLand(fx, nonce, '2');
|
||||
expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)');
|
||||
expect(drift.out).toMatch(/^QUEUE_TRAIN_FROZEN: /m);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'freeze' });
|
||||
const lockSha = originRev(fx, LAND_LOCK_REF);
|
||||
|
||||
fragmentPr(fx, '3');
|
||||
const refused = land(fx, '3', ['--break-lock', '--break-train-lock']);
|
||||
expect(refused.status).toBe(EXIT.refused);
|
||||
expect(refused.err).toContain('(QUEUE_TRAIN_FROZEN)');
|
||||
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
|
||||
|
||||
const h4 = fragmentPr(fx, '4');
|
||||
const repair = queue(fx, ['train', 'build', '--repair', '--prs', `4@${h4}`]);
|
||||
expect(repair.status, repair.err).toBe(EXIT.ok);
|
||||
const rn = nonceOf(repair.out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
|
||||
const taken = trainLand(fx, rn, '4');
|
||||
expect(taken.out, taken.err).toMatch(new RegExp(`^LOCK: repair train ${rn} takes over the freeze lock of train ${nonce}`, 'm'));
|
||||
expect(originLock(fx)).toMatchObject({ train: rn, slot: 1, pr: '4', phase: 'freeze' });
|
||||
expect(land(fx, '3', ['--break-lock', '--break-train-lock']).err).toContain('(QUEUE_TRAIN_FROZEN)');
|
||||
const cleared = trainLand(fx, rn, '4');
|
||||
expect(cleared.status, cleared.err).toBe(EXIT.ok);
|
||||
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
|
||||
import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
|
||||
import {
|
||||
TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson,
|
||||
type TrainFixture,
|
||||
@@ -28,8 +28,10 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n';
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
|
||||
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
|
||||
// Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote.
|
||||
const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
|
||||
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled });
|
||||
@@ -111,6 +113,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const rn = nonceOf(queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
|
||||
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
|
||||
markMerged(fx, '3');
|
||||
const cleared = queue(fx, ['verify', '--pr', '3', '--train', rn], {}, coord);
|
||||
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
|
||||
expect(next(cleared.out)).toBe(`NEXT: gh pr view 2 --json state,headRefOid (unreadable at the freeze: #2@${h2.slice(0, 12)}); seat each that is OPEN in a train`);
|
||||
@@ -125,6 +128,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
|
||||
fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; });
|
||||
@@ -157,6 +161,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
|
||||
const tree = fx.repo.git('rev-parse', `${later}^{tree}`);
|
||||
@@ -181,6 +186,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n';
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' });
|
||||
const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
|
||||
|
||||
@@ -10,47 +10,13 @@ import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup } from './helpers/restamp-fixture';
|
||||
import {
|
||||
GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture,
|
||||
GREEN_GATE, RED_GATE, TRAIN_POLICY, fragmentPr, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, wrapLand as wrap, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const fixtures: TrainFixture[] = [];
|
||||
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
||||
|
||||
/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
|
||||
function wrap(fx: TrainFixture): void {
|
||||
const dir = path.join(fx.repo.root, 'gh-land-wrap');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
|
||||
fs.writeFileSync(path.join(dir, 'gh'), [
|
||||
'#!/usr/bin/env node',
|
||||
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
|
||||
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
|
||||
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
|
||||
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
|
||||
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
|
||||
" const before=rev('main');let tree=rev(want+'^{tree}');",
|
||||
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
|
||||
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
|
||||
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
|
||||
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
fx.env.PATH = `${dir}:${fx.env.PATH}`;
|
||||
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
|
||||
}
|
||||
|
||||
function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
|
||||
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
|
||||
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
|
||||
readyReceipt(fx, pr, head);
|
||||
return head;
|
||||
}
|
||||
function stampedTrain(gate = GREEN_GATE, policy: Record<string, unknown> = POLICY) {
|
||||
const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
|
||||
const h1 = fragmentPr(fx, '1');
|
||||
@@ -190,10 +156,15 @@ describe('stamped trains: build and land', () => {
|
||||
}, 300_000);
|
||||
|
||||
test('a failed stamp push with the branch still at the pin fails retryably instead of reporting a move', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
const { fx, h1, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
|
||||
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
const refused = land(fx, nonce, '1');
|
||||
expect(refused.err).toMatch(/the land lock push to origin failed and the lock there is unchanged; nothing was pushed.*\(QUEUE_NOT_READY\)/);
|
||||
expect(refused.err).not.toContain('another lander');
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1);
|
||||
fs.writeFileSync(hook, '#!/bin/sh\nwhile read old new ref; do [ "$ref" = refs/heads/pr-1 ] && exit 1; done\nexit 0\n', { mode: 0o755 });
|
||||
const failed = land(fx, nonce, '1');
|
||||
expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/);
|
||||
expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED');
|
||||
|
||||
@@ -6,7 +6,7 @@ import { mergeChangelogs, parseChangelog } from '../lib/changelog-check';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
|
||||
import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
|
||||
import {
|
||||
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson,
|
||||
type TrainFixture,
|
||||
@@ -27,21 +27,24 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord);
|
||||
|
||||
const s1 = laneStamp(fx, '1', h1);
|
||||
const pre1 = v('1');
|
||||
expect(pre1.status).toBe(EXIT.ok);
|
||||
expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`);
|
||||
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
|
||||
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
|
||||
const early = v('2');
|
||||
expect(early.status).toBe(EXIT.fail);
|
||||
expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
||||
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
|
||||
markMerged(fx, '1');
|
||||
const post1 = v('1');
|
||||
expect(post1.status).toBe(EXIT.ok);
|
||||
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(1 stamp file\(s\)\)\)$/m);
|
||||
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(2 stamp file\(s\)\)\)$/m);
|
||||
expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
|
||||
expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`);
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
|
||||
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
|
||||
|
||||
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' });
|
||||
const mismatch = v('2');
|
||||
@@ -69,8 +72,16 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
|
||||
expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)');
|
||||
expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1);
|
||||
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h3}`);
|
||||
// The broken main still carries T2's [Unreleased] section, so #3's lane stamps its own entry by hand.
|
||||
fx.repo.git('checkout', '-q', '-B', 'lane3', h3);
|
||||
fs.writeFileSync(path.join(fx.repo.work, 'VERSION'), '1.2.6.0\n');
|
||||
fs.writeFileSync(path.join(fx.repo.work, 'CHANGELOG.md'), fx.repo.git('show', `${h3}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.6.0] - 2026-10-18') + '\n');
|
||||
fx.repo.git('commit', '-qam', 'slot stamp #3');
|
||||
const s3 = fx.repo.git('rev-parse', 'HEAD');
|
||||
fx.repo.git('push', '-q', '-f', 'origin', `${s3}:refs/pull/3/head`);
|
||||
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s3}`);
|
||||
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
|
||||
markMerged(fx, '3');
|
||||
const cleared = v('3', rn);
|
||||
expect(cleared.status).toBe(EXIT.ok);
|
||||
expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m);
|
||||
@@ -85,14 +96,10 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord);
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', '-B', 'lane1', h1);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
|
||||
const stamped = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`);
|
||||
const stamped = laneStamp(fx, '1', h1);
|
||||
const ok = v();
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`);
|
||||
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (5 stamp file(s)))`);
|
||||
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`);
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`);
|
||||
setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40)));
|
||||
@@ -133,6 +140,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
|
||||
const { repo } = fx;
|
||||
@@ -142,6 +150,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
repo.git('rm', '-rq', '.');
|
||||
repo.git('checkout', t.prefixes[2].commit, '--', '.');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18') + '\n');
|
||||
if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text);
|
||||
repo.git('add', '-A');
|
||||
const tree = repo.git('write-tree');
|
||||
@@ -224,8 +233,10 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
|
||||
// Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed).
|
||||
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
|
||||
const s1 = laneStamp(fx, '1', h1);
|
||||
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
|
||||
|
||||
// Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent.
|
||||
@@ -249,6 +260,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
// The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue.
|
||||
const main2 = publishMain(fx, laneHead, '1.2.5.0');
|
||||
expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`));
|
||||
markMerged(fx, '2');
|
||||
const post = v('2');
|
||||
expect([post.status, post.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`);
|
||||
@@ -256,3 +268,133 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN');
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe('verify --train proves the slot stamp and a GitHub merge (#3531)', () => {
|
||||
/** A main commit that carries an unstamped [Unreleased] entry, the way a bare commit after a no-commit merge shipped #3527. */
|
||||
function unstampedMain(fx: TrainFixture, entry: string): string {
|
||||
const { repo } = fx;
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n${entry}\n`));
|
||||
repo.git('commit', '-qam', 'merged without its stamp');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
}
|
||||
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`);
|
||||
|
||||
test('#3527: an unstamped slot head (the bare pin, or a no-commit base merge committed without the restamp it wrote) refuses QUEUE_TRAIN_UNSTAMPED naming both versions; the stamped head passes with STAMP_VERSION from the head', () => {
|
||||
const fx = make();
|
||||
const { h1, h2, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
|
||||
const bare = v('1');
|
||||
expect(bare.status).toBe(EXIT.refused);
|
||||
expect(bare.err).toMatch(/#1's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.3\.0 is not above main's 1\.2\.3\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
|
||||
expect(bare.out).not.toContain('MERGE_CONDITION:');
|
||||
expect(next(bare.out)).toMatch(/^NEXT: #1's lane runs gstack-restamp --next on its head, commits what restamp staged/);
|
||||
|
||||
const s1 = laneStamp(fx, '1', h1);
|
||||
const ok = v('1');
|
||||
expect(ok.status, ok.err).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain('STAMP_VERSION: 1.2.4.0');
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
|
||||
const unread = queue(fx, ['verify', '--pr', '1', '--train', nonce, '--no-fetch'], {}, coord);
|
||||
expect(unread.status).toBe(EXIT.refused);
|
||||
expect(unread.err).toMatch(/#1's current head was not read \(--no-fetch\).*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(unread.out).not.toContain('MERGE_CONDITION:');
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
|
||||
|
||||
laneStamp(fx, '2', h2, { mergeMain: true, dropStamp: true });
|
||||
const dropped = v('2');
|
||||
expect(dropped.status, dropped.err).toBe(EXIT.refused);
|
||||
expect(dropped.err).toMatch(/#2's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.4\.0 is not above main's 1\.2\.4\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
|
||||
expect(dropped.out).not.toContain('MERGE_CONDITION:');
|
||||
expect(fs.existsSync(freezeFile(fx))).toBe(false);
|
||||
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
|
||||
const stamped = v('2');
|
||||
expect(stamped.status, stamped.err).toBe(EXIT.ok);
|
||||
expect(stamped.out).toContain('STAMP_VERSION: 1.2.5.0');
|
||||
expect(line(stamped.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
|
||||
}, 300_000);
|
||||
|
||||
test('the CHANGELOG rule is named: a head whose VERSION moved but whose entry is still [Unreleased] refuses', () => {
|
||||
const fx = make();
|
||||
const { h1, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', '-B', 'lane1', h1);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'VERSION only');
|
||||
repo.git('push', '-q', '-f', 'origin', `${repo.git('rev-parse', 'HEAD')}:refs/pull/1/head`);
|
||||
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/is not stamped above main: changelog: CHANGELOG\.md's top section at the head is \[Unreleased\], not \[1\.2\.4\.0\].*\(QUEUE_TRAIN_UNSTAMPED\)/);
|
||||
}, 300_000);
|
||||
|
||||
test('claim-at-open: a pin that carries its own stamp verifies at head == pin', () => {
|
||||
const fx = make({ ...TRAIN_POLICY, queue_mode: 'claim-at-open' });
|
||||
const h1 = openPr(fx, '1', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
|
||||
expect(r.status, r.err).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('STAMP_VERSION: 1.2.4.0');
|
||||
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
|
||||
}, 300_000);
|
||||
|
||||
test('#3530: an open release-only PR whose stamp-only diff makes main equal its prefix modulo stamps is not reported merged; it takes the pre-merge path', () => {
|
||||
const fx = make();
|
||||
unstampedMain(fx, '- #3527 change');
|
||||
const h5 = openPr(fx, '5', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') });
|
||||
readyReceipt(fx, '5', h5);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `5@${h5}`, '--urgent']).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const r = queue(fx, ['verify', '--pr', '5', '--train', nonce], {}, coordinatorClone(fx.repo));
|
||||
expect(r.out).not.toMatch(/^VERIFY_TRAIN: #5 merged/m);
|
||||
expect(r.status, r.err).toBe(EXIT.ok);
|
||||
expect(r.out).toContain(`VERIFY_TRAIN: #5 may merge as slot 1 of train ${nonce}`);
|
||||
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h5}`);
|
||||
expect(trainJson(fx, nonce).merged).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
test('no false freeze: main at the next prefix while GitHub reports the PR OPEN, or cannot be read, refuses QUEUE_PROOF_MISSING and never freezes; MERGED confirms', () => {
|
||||
const fx = make();
|
||||
const { h1, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
laneStamp(fx, '1', h1);
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
publishMain(fx, t.prefixes[2].commit, '1.2.5.0');
|
||||
|
||||
const open = v('2');
|
||||
expect(open.status).toBe(EXIT.refused);
|
||||
expect(open.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(open.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m);
|
||||
expect(open.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
|
||||
expect(next(open.out)).toContain('rerun once GitHub reports #2 MERGED');
|
||||
const saved = fx.read().prs['2'];
|
||||
fx.write(d => { delete d.prs['2']; });
|
||||
const unread = v('2');
|
||||
expect(unread.err).toContain('(QUEUE_PROOF_MISSING)');
|
||||
expect(unread.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
|
||||
expect(fs.existsSync(freezeFile(fx))).toBe(false);
|
||||
fx.write(d => { d.prs['2'] = { ...(saved as object), state: 'MERGED' }; });
|
||||
const done = v('2');
|
||||
expect(done.status, done.err).toBe(EXIT.ok);
|
||||
expect(done.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`);
|
||||
}, 300_000);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* #3541 (the /ship half): `gstack-ship-receipt write` with no --session-kind
|
||||
* and no GSTACK_SESSION_KIND asks bin/gstack-session-kind, a bash script.
|
||||
* Spawned by path it never starts on Windows (no shebangs), so every receipt
|
||||
* written there carried no session_kind.
|
||||
*
|
||||
* Windows-safe by construction: both bins launch through
|
||||
* lib/script-invocation.ts (Git Bash on win32), state lives under os.tmpdir().
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
let tmp = '', repo = '', env: NodeJS.ProcessEnv = {};
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-kind-')));
|
||||
repo = path.join(tmp, 'repo');
|
||||
fs.mkdirSync(repo);
|
||||
const gitconfig = path.join(tmp, 'gitconfig');
|
||||
fs.writeFileSync(gitconfig, '');
|
||||
env = {
|
||||
...process.env, HOME: tmp, USERPROFILE: tmp, GSTACK_STATE_ROOT: path.join(tmp, 'state'), GSTACK_HOME: path.join(tmp, 'state'), GSTACK_SESSION_KIND: '',
|
||||
GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined,
|
||||
};
|
||||
const git = (...args: string[]) => {
|
||||
const r = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
};
|
||||
git('init', '-q', '-b', 'main');
|
||||
fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n');
|
||||
git('add', 'a.txt');
|
||||
git('-c', 'user.email=fixture@example.test', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture');
|
||||
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
|
||||
git('switch', '-q', '-c', 'feat');
|
||||
});
|
||||
|
||||
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
test('write without a session kind records what gstack-session-kind prints (#3541)', () => {
|
||||
const kind = runScriptSync(path.join(ROOT, 'bin', 'gstack-session-kind'), [], { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(kind.status, kind.stderr).toBe(0);
|
||||
const body = path.join(tmp, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
const w = runScriptSync(path.join(ROOT, 'bin', 'gstack-ship-receipt'), ['write', '--pr', '1', '--base', 'main', '--body', body, '--json'], {
|
||||
cwd: repo, env, encoding: 'utf8', timeout: 120_000,
|
||||
});
|
||||
expect(w.error).toBeUndefined();
|
||||
expect(w.status, w.stderr).toBe(0);
|
||||
expect(JSON.parse(w.stdout).session_kind).toBe(kind.stdout.trim());
|
||||
}, 150_000);
|
||||
@@ -202,6 +202,53 @@ describe('a version stamp after review (GBRA-251, gbrain #8095)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => {
|
||||
// Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older
|
||||
// than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the
|
||||
// base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row
|
||||
// at or older than the fork point, moved the same way still stops;
|
||||
// fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or
|
||||
// drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer
|
||||
const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] });
|
||||
const notice = (...rows: [string, string][]) => `export const NOTICES = [\n${rows.map(([v, text]) => ` { since: '${v}', text: '${text}' },`).join('\n')}\n];\n`;
|
||||
const lostSlot = (ownRow: string) => {
|
||||
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old']) },
|
||||
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], [ownRow, 'mine']) });
|
||||
f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs']) });
|
||||
mergeMain(f, { VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], [ownRow, 'mine']) });
|
||||
return { f, reviewed: wtree(f), reviewedBase: mergeBase(f), post: snapshot(f) };
|
||||
};
|
||||
const behaviorStop = 'STOP: behavior, tests or build inputs changed since review (src/notice.ts): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)';
|
||||
|
||||
test('the moved stale stamp is metadata; a moved base row or pre-fork row stops', () => {
|
||||
const own = lostSlot('1.0.1.0');
|
||||
own.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); own.f.commit('restamp 1.0.3.0');
|
||||
const ok = finish(own.f, own.reviewed, own.post, ['--reviewed-base', own.reviewedBase]);
|
||||
expect([lineOf(ok.stdout, 'ROUTE'), lineOf(ok.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]);
|
||||
|
||||
const base = lostSlot('1.0.1.0');
|
||||
base.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); base.f.commit('restamp plus the base row');
|
||||
const b = finish(base.f, base.reviewed, base.post, ['--reviewed-base', base.reviewedBase]);
|
||||
expect([b.status, lineOf(b.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
|
||||
const old = lostSlot('0.9.5.0');
|
||||
old.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); old.f.commit('moves a pre-fork row');
|
||||
const o = finish(old.f, old.reviewed, old.post, ['--reviewed-base', old.reviewedBase]);
|
||||
expect([o.status, lineOf(o.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
|
||||
const atFork = lostSlot('1.0.0.0');
|
||||
atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version');
|
||||
const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]);
|
||||
expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
|
||||
const edited = lostSlot('1.0.1.0');
|
||||
edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin');
|
||||
edited.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); edited.f.commit('moves a merged base row the base later edited');
|
||||
const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]);
|
||||
expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => {
|
||||
// Value: protects=a base edit next to the branch's unchanged hunk asks for a scoped review of only those hunks (finish
|
||||
// context=N, carry RECEIPT_CARRY_SCOPED_REVIEW), and a clean scoped review lets it through; fails_when=it forces the
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* GST-882: the skill-start fence must be plain shell a static resolver can
|
||||
* follow. Hermes's Tirith 0.4.2 blocks a grouped fallback
|
||||
* (`[ -d … ]||{ …; exit 1;}`) and any variable-selected command with HIGH
|
||||
* "Nested executable body could not be resolved", which no allowlist entry can
|
||||
* clear, so every gstack skill start was denied. An install-root render names
|
||||
* the literal start path; every render keeps a loud failure when gstack is
|
||||
* missing.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { ALL_HOST_CONFIGS } from '../hosts';
|
||||
import { generatePreambleBash } from '../scripts/resolvers/preamble/generate-preamble-bash';
|
||||
import { insertRuntimePreludes, runtimeRootPrelude } from '../scripts/resolvers/runtime-root';
|
||||
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gst882-'));
|
||||
const ctx = (host: string, installRoot: string | null) =>
|
||||
({ host, paths: HOST_PATHS[host], skillName: 'ship', tmplPath: '', model: 'claude', installRoot }) as TemplateContext;
|
||||
const startFence = (c: TemplateContext) => insertRuntimePreludes(generatePreambleBash(c), c).match(/```bash\n([\s\S]*?)\n```/)![1];
|
||||
const GROUPED = /(?:^|[;&|]\s*)\{\s|exit 1;\s*\}/m;
|
||||
const sh = (body: string, env: Record<string, string>, cwd = tmp) =>
|
||||
spawnSync('bash', ['-uc', body], { cwd, encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', ...env } });
|
||||
function stubStart(root: string) {
|
||||
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'lib'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'bin', 'gstack-skill-start'), '#!/bin/sh\necho "STARTED $0 $*"\n', { mode: 0o755 });
|
||||
}
|
||||
|
||||
describe('skill-start fence is plain shell on every host (GST-882)', () => {
|
||||
for (const host of ALL_HOST_CONFIGS) {
|
||||
for (const installRoot of [null, path.join(tmp, `${host.name}-install`)]) {
|
||||
const label = `${host.name} ${installRoot ? 'install-root' : 'checked-in'} render`;
|
||||
test(`${label}: no grouped fallback`, () => {
|
||||
expect(startFence(ctx(host.name, installRoot))).not.toMatch(GROUPED);
|
||||
});
|
||||
if (!host.usesEnvVars) continue;
|
||||
test(`${label}: a missing install fails loudly`, () => {
|
||||
const r = sh(startFence(ctx(host.name, installRoot)), { HOME: path.join(tmp, 'empty-home') });
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('gstack: no install found');
|
||||
expect(r.stderr).toContain(`./setup --host ${host.name}`);
|
||||
});
|
||||
}
|
||||
if (!host.usesEnvVars) continue;
|
||||
test(`${host.name} install-root render: the start command is a literal path that runs`, () => {
|
||||
const root = path.join(tmp, `${host.name}-literal`);
|
||||
const fence = startFence(ctx(host.name, root));
|
||||
const command = fence.split('\n').at(-1)!;
|
||||
expect(command).toStartWith(`"${root}/bin/gstack-skill-start" --skill "ship"`);
|
||||
expect(fence).not.toContain('$');
|
||||
stubStart(root);
|
||||
const r = sh(fence, { HOME: path.join(tmp, 'empty-home') });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`STARTED ${root}/bin/gstack-skill-start --skill ship`);
|
||||
});
|
||||
test(`${host.name} checked-in render: finds the global install`, () => {
|
||||
const home = path.join(tmp, `${host.name}-home`);
|
||||
const global = path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot);
|
||||
stubStart(global);
|
||||
const r = sh(startFence(ctx(host.name, null)), { HOME: home });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('STARTED ');
|
||||
expect(r.stdout).toContain('--skill ship');
|
||||
});
|
||||
test(`${host.name} checked-in render: the start lookup picks the root later fences pick`, () => {
|
||||
const base = path.join(tmp, `${host.name}-parity`);
|
||||
const home = path.join(base, 'home');
|
||||
const repo = path.join(base, 'repo');
|
||||
const exported = path.join(base, 'exported');
|
||||
stubStart(path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot));
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync('git', ['init', '-q', repo], { timeout: 10_000 });
|
||||
const c = ctx(host.name, null);
|
||||
const lookup = startFence(c).split('\n').slice(0, -1).join('\n');
|
||||
const root = (body: string, env: Record<string, string>) => sh(`${body}\necho "ROOT=$GSTACK_ROOT"`, env, repo).stdout.trim().split('\n').at(-1);
|
||||
for (const layout of ['global', 'repo-local', 'exported']) {
|
||||
if (layout === 'repo-local') stubStart(path.join(repo, host.localSkillRoot));
|
||||
if (layout === 'exported') stubStart(exported);
|
||||
const env: Record<string, string> = layout === 'exported' ? { HOME: home, GSTACK_ROOT: exported } : { HOME: home };
|
||||
expect(root(lookup, env)).toBe(root(runtimeRootPrelude(c), env));
|
||||
if (layout === 'exported') expect(root(lookup, env)).toBe(`ROOT=${exported}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -196,7 +196,7 @@ describe('test-free-shards: exclusive host-state phase', () => {
|
||||
let child: ReturnType<typeof Bun.spawn> | undefined;
|
||||
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
for (const file of ['scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'lib/state-root.ts',
|
||||
for (const file of ['scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'scripts/lib/shard-jobs.ts', 'lib/state-root.ts',
|
||||
'test/helpers/paid-test-set.ts', 'test/helpers/touchfiles.ts', 'test/helpers/touchfiles-data.ts', 'test/helpers/test-selection.ts']) {
|
||||
const target = path.join(directory, file);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
|
||||
@@ -112,14 +112,60 @@ describe('W7a flake ledger reader', () => {
|
||||
const client = fakeClient([
|
||||
[/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: runs })],
|
||||
[/runs\/100\/artifacts/, () => ({ artifacts: [{ id: 1, name: 'flake-ledger-3', size_in_bytes: 10 }, { id: 2, name: 'free-result-3', size_in_bytes: 10 }] })],
|
||||
[/runs\/101\/artifacts/, () => ({ artifacts: [{ id: 3, name: 'flake-ledger-7', size_in_bytes: 10, expired: true }] })],
|
||||
[/runs\/\d+\/artifacts/, () => ({ artifacts: [] })],
|
||||
[/runs\/101\/artifacts/, () => ({ artifacts: [{ id: 3, name: 'flake-ledger-7', size_in_bytes: 10, expired: true }, { id: 4, name: 'free-result-7', size_in_bytes: 10 }] })],
|
||||
[/runs\/\d+\/artifacts/, () => ({ artifacts: [{ id: 5, name: 'free-result-1', size_in_bytes: 10 }] })],
|
||||
], { 1: { 'flake-ledger.jsonl': ledger('test/a.test.ts') + ledger('test/a.test.ts') } });
|
||||
const window = readCiFlakeLedgers({ client, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') });
|
||||
expect(window).toMatchObject({ runs: 20, flakyRuns: 1, enforceable: true, files: [{ file: 'test/a.test.ts', flakyRuns: 1, entries: 2, rate: 0.05, failing: false }] });
|
||||
expect(client.calls[0]).toContain('branch=main');
|
||||
});
|
||||
|
||||
// A reused main push (#3709) uploads no artifact and its reuse job succeeds; a real run uploads free-plan and free-result-*.
|
||||
const reuseClient = (runs: ReturnType<typeof run>[], kind: (id: number) => 'reused' | 'real' | 'flaky', jobs?: () => unknown) => fakeClient([
|
||||
[/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: runs })],
|
||||
[/runs\/\d+\/artifacts/, apiPath => {
|
||||
const k = kind(Number(/runs\/(\d+)\/artifacts/.exec(apiPath)![1]));
|
||||
return { artifacts: k === 'reused' ? [] : [{ id: 8, name: 'free-plan', size_in_bytes: 10 },
|
||||
...(k === 'flaky' ? [{ id: 9, name: 'flake-ledger-3', size_in_bytes: 10 }] : [])] };
|
||||
}],
|
||||
[/runs\/\d+\/attempts\/1\/jobs/, jobs ?? (() => ({ jobs: [{ id: 1, name: 'reuse', conclusion: 'success' }, { id: 2, name: 'free-plan', conclusion: 'skipped' },
|
||||
{ id: 3, name: 'free-tests', conclusion: 'success' }] }))],
|
||||
], { 9: { 'flake-ledger.jsonl': ledger('test/a.test.ts') } });
|
||||
|
||||
test('reused main pushes that ran no shards stay out of the window and its denominator; the window refills with older runs that ran shards (#3725)', () => {
|
||||
// Value: protects=test:health --enforce still flags a file flaky in >5% of runs that executed shards when main pushes reuse their PR's run; fails_when=a reused run (no artifacts, reuse job green) counts as a zero-flake run again; why_new=#3725, after #3709 reused runs diluted 2/20 (10%) to 2/45 (4.4%); seam=GhClient fake; no_claim=the live artifact retention
|
||||
const recent = Array.from({ length: 45 }, (_, i) => run(200 + i, { created_at: day(1 + i * 0.1) }));
|
||||
const older = Array.from({ length: 10 }, (_, i) => run(300 + i, { created_at: day(10 + i) }));
|
||||
const client = reuseClient([...recent, ...older], id => (id === 200 || id === 203 ? 'flaky' : id < 300 && (id - 200) % 3 !== 0 ? 'reused' : 'real'));
|
||||
const window = readCiFlakeLedgers({ client, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') });
|
||||
expect(window).toMatchObject({ runs: 20, flakyRuns: 2, skipped: 30, enforceable: true, since: day(14),
|
||||
files: [{ file: 'test/a.test.ts', flakyRuns: 2, rate: 0.1, failing: true }] });
|
||||
expect(window.note).toBeUndefined();
|
||||
expect(client.calls.filter(call => call.includes('/artifacts')).length).toBe(50);
|
||||
expect(client.calls.filter(call => call.includes('/jobs')).length).toBe(30);
|
||||
expect(flakeCheck(window)).toMatchObject({ status: 'fail', detail: 'test/a.test.ts: flaky in 2/20 main runs (10.0% > 5%); 30 reused main push(es) left out' });
|
||||
});
|
||||
|
||||
test('the reuse check spends at most 40 job listings; past the cap or on a failed listing a run counts as before, and the report says so', () => {
|
||||
// Value: protects=the reuse lookups stay bounded and fail toward the pre-#3725 count visibly; fails_when=lookups are uncapped, or a capped or failed lookup drops the run or stays silent; why_new=#3725 adds a per-run job listing; seam=GhClient fake; no_claim=none beyond protects
|
||||
const runs = [...Array.from({ length: 20 }, (_, i) => run(400 + i, { created_at: day(1 + i * 0.1) })),
|
||||
...Array.from({ length: 60 }, (_, i) => run(500 + i, { created_at: day(8 + i * 0.5) }))];
|
||||
const kind = (id: number) => (id === 400 ? 'flaky' : id === 401 ? 'real' : 'reused');
|
||||
const client = reuseClient(runs, kind);
|
||||
const window = readCiFlakeLedgers({ client, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') });
|
||||
expect(window).toMatchObject({ runs: 20, flakyRuns: 1, skipped: 40, enforceable: true, since: day(8 + 39 * 0.5),
|
||||
files: [{ file: 'test/a.test.ts', rate: 0.05, failing: false }] });
|
||||
expect(window.note).toBe('18 run(s) with no artifacts not checked for main-push reuse (cap of 40 job listings reached); counted as before #3725');
|
||||
expect(client.calls.filter(call => call.includes('/jobs')).length).toBe(40);
|
||||
expect(client.calls.filter(call => call.includes('/artifacts')).length).toBe(60);
|
||||
expect(flakeCheck(window).detail).toBe('no file above 5% over 20 main runs; 40 reused main push(es) left out; ' + window.note);
|
||||
|
||||
const failing = reuseClient(runs, kind, () => { throw new Error('gh api failed: HTTP 502: Bad Gateway'); });
|
||||
const unchecked = readCiFlakeLedgers({ client: failing, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') });
|
||||
expect(unchecked).toMatchObject({ runs: 20, skipped: 0, since: day(1 + 19 * 0.1), files: [{ file: 'test/a.test.ts', rate: 0.05 }] });
|
||||
expect(unchecked.note).toStartWith('18 run(s) with no artifacts not checked for main-push reuse (GitHub API server error');
|
||||
});
|
||||
|
||||
test('the job-summary table names files, counts and shards', () => {
|
||||
expect(formatFlakeSummary([])).toContain('None: every file passed on its first run.');
|
||||
const table = formatFlakeSummary(parseFlakeLedger(ledger('test/a.test.ts') + ledger('test/a.test.ts') + ledger('test/b.test.ts')));
|
||||
@@ -188,7 +234,7 @@ describe('collectHealth over recorded fixture data', () => {
|
||||
{ id: 4, name: 'redispatch', conclusion: 'skipped', started_at: null, completed_at: null }] })],
|
||||
[/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: [run(60), run(61, { conclusion: 'failure' })] })],
|
||||
[/runs\/60\/artifacts/, () => ({ artifacts: [{ id: 61, name: 'flake-ledger-2', size_in_bytes: 10 }] })],
|
||||
[/runs\/61\/artifacts/, () => ({ artifacts: [] })],
|
||||
[/runs\/61\/artifacts/, () => ({ artifacts: [{ id: 62, name: 'free-result-1', size_in_bytes: 10 }] })],
|
||||
[/workflows\/windows-smoke\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
|
||||
], {
|
||||
11: { 'manifest.json': JSON.stringify({ prCoverage: { mode: 'full-fallback', e2e: ['gate-a', 'gate-b'], judges: ['judge-a'],
|
||||
|
||||
@@ -35,6 +35,7 @@ const HOOK_FILES = [
|
||||
'lib/result-codes-budget.ts',
|
||||
'lib/result-codes-eval.ts',
|
||||
'lib/result-codes-plan.ts',
|
||||
'lib/result-codes-autoplan.ts',
|
||||
];
|
||||
const SLUG = 'stop-hook-slug-test';
|
||||
const BUDGET_MS = 2000;
|
||||
|
||||
@@ -20,7 +20,7 @@ afterAll(() => fs.rmSync(SCRATCH, { recursive: true, force: true }));
|
||||
|
||||
const COPIED = ['scripts/ubicloud/pregate-remote.sh', 'bin/gstack-wtree', 'bin/gstack-path-spelling.sh',
|
||||
'scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts',
|
||||
'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'lib/state-root.ts', 'test/helpers/paid-test-set.ts',
|
||||
'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'scripts/lib/shard-jobs.ts', 'lib/state-root.ts', 'test/helpers/paid-test-set.ts',
|
||||
'test/helpers/touchfiles.ts', 'test/helpers/touchfiles-data.ts', 'test/helpers/test-selection.ts'];
|
||||
const TESTS: Record<string, string> = {
|
||||
'test/alpha.test.ts': "import { expect, test } from 'bun:test';\ntest('alpha', () => { expect(1).toBe(1); });\n",
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
const err = r.stderr.toString();
|
||||
expect(r.exitCode, err).toBe(0);
|
||||
expect(err).toMatch(/UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/);
|
||||
expect(err).toMatch(/carries a 3h TTL \(this owner's next up or reap destroys it once it expires\); if this process dies first: /);
|
||||
expect(f.listed()).toBe('eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n');
|
||||
// Value: protects=UBI_LEFT_FILE carries the same leftover line pregate-remote.sh parses, apart from the command's output; fails_when=the file is not written or its format drifts from pregate-remote.sh's parser; why_new=the pregate stub hand-copies the format; seam=none; no_claim=real VM teardown (fake API and ssh)
|
||||
expect(readFileSync(leftFile, 'utf8')).toMatch(/^UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n$/);
|
||||
|
||||
@@ -210,12 +210,12 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
}, 90_000);
|
||||
|
||||
test('reap reports a failed list and a failed destroy as failures; UBI_TTL_HOURS rejects leading zeros and more than three digits', () => {
|
||||
const f = fakeVm({ listed: 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\n' });
|
||||
const f = fakeVm({ listed: 'eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1\n' });
|
||||
try {
|
||||
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-other-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n');
|
||||
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n');
|
||||
const destroyFails = run(f, ['reap']);
|
||||
expect(destroyFails.exitCode).toBe(1);
|
||||
expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-other-1000000000-bbbb-t1');
|
||||
expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-thrd1-1000000000-bbbb-t1');
|
||||
expect(destroyFails.stderr.toString()).toContain('reap: 1 of 1 expired VMs could not be destroyed');
|
||||
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\ncat >/dev/null; echo "down" > "$out"; printf 503\n');
|
||||
const listFails = run(f, ['reap']);
|
||||
|
||||
@@ -204,25 +204,25 @@ esac
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('up reaps expired TTL VMs of any owner first, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => {
|
||||
const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n';
|
||||
test('up reaps this owner\'s expired TTL VMs first and leaves another owner\'s, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => {
|
||||
const listed = 'eu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n';
|
||||
const f = fakeApi({ destroy: 200, listed });
|
||||
try {
|
||||
const r = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '31' }, timeout: 90_000 });
|
||||
expect(r.exitCode).toBe(1);
|
||||
const err = r.stderr.toString();
|
||||
expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-other-1000000000-bbbb-t1 \(owner other; its TTL ended \d+ min ago\)/);
|
||||
expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/);
|
||||
expect(err).toMatch(/quota preflight refused eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 \(standard-16\): it needs 16 vCPUs and the project's VMs already use 16 of 31/);
|
||||
expect(err).toContain('create refused before any request: vCPU quota exhausted');
|
||||
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
|
||||
expect(destroyed).toHaveLength(1);
|
||||
expect(destroyed[0]).toContain('ubirun-other-1000000000-bbbb-t1');
|
||||
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1');
|
||||
expect(f.calls()).not.toContain('"create"');
|
||||
expect(existsSync(f.record)).toBe(false);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys expired TTLs and this owner\'s old TTL-less names, never unexpired, recent, another owner\'s TTL-less, untagged or foreign ones', () => {
|
||||
test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys this owner\'s expired TTLs and old TTL-less names, never unexpired, recent, another owner\'s expired or TTL-less, untagged or foreign ones', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const listed = `eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-thrd1-9999999999-dddd-t1\neu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-central-h1 ubirun-1000000000-cccc\neu-central-h1 ubirun-thrd1-${now - 3 * 3600}-eeee\neu-central-h1 ubirun-gbra240-1000000000-gggg\neu-central-h1 team-db-1000000000-ffff\n`;
|
||||
const f = fakeApi({ destroy: 200, listed });
|
||||
@@ -235,7 +235,8 @@ esac
|
||||
expect(reap.exitCode).toBe(0);
|
||||
expect(reap.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-1000000000-aaaa \(owner thrd1; \d+h old, legacy sweep at 4h\)/);
|
||||
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy')).map(l => l.match(/ubirun-[a-z0-9-]+|team-db-[a-z0-9-]+/)?.[0]).sort();
|
||||
expect(destroyed).toEqual(['ubirun-other-1000000000-bbbb-t1', 'ubirun-thrd1-1000000000-aaaa']);
|
||||
expect(destroyed).toEqual(['ubirun-thrd1-1000000000-aaaa']);
|
||||
expect(reap.stderr.toString()).toContain('reap: left 1 expired VMs of other owners; only their owners reap them');
|
||||
expect(f.run('reap').stderr.toString()).toContain('reap: no expired VMs');
|
||||
// Value: protects=UBI_LEGACY_HOURS sets reap's bound for TTL-less ubirun names (a 3h-old name is spared at 4h, reaped at 2h); fails_when=reap ignores UBI_LEGACY_HOURS and keeps a fixed bound; why_new=only the default bound and the validation are tested; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
|
||||
const lowered = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_LEGACY_HOURS: '2' }, timeout: 90_000 });
|
||||
@@ -243,13 +244,34 @@ esac
|
||||
expect(lowered.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-\d{10}-eeee \(owner thrd1; 3h old, legacy sweep at 2h\)/);
|
||||
expect(f.calls()).toMatch(/ubirun-thrd1-\d{10}-eeee[^\n]*destroy/);
|
||||
// Value: protects=the legacy sweep never destroys another lane's or an untagged TTL-less VM (GST-324: 22 gbra240 eval VMs lost); fails_when=the sweep matches any owner again; why_new=the cross-owner sweep destroyed a paid eval; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
|
||||
expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc)[^\n]*destroy/);
|
||||
expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc|bbbb)[^\n]*destroy/);
|
||||
const ownerless = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: '', UBI_LEGACY_HOURS: '1' }, timeout: 90_000 });
|
||||
expect(ownerless.exitCode).toBe(0);
|
||||
expect(ownerless.stderr.toString()).not.toContain('reaping TTL-less');
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 30_000);
|
||||
|
||||
// Value: protects=reap and every up destroy only the caller's own VMs, never another owner's, even one whose TTL ran out (the 2026-10-30 owner-scoped-deletes rule; GST-324 lost 22 of gbrain's eval VMs to a cross-owner sweep); fails_when=reap judges another owner's VM by its TTL again; why_new=the old cases asserted the cross-owner TTL reap this rule forbids; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
|
||||
test('reap and up destroy only the caller\'s own expired VMs: another owner\'s and an untagged expired VM survive (GST-573)', () => {
|
||||
const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-1000000000-cccc-t1\neu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\n';
|
||||
const f = fakeApi({ destroy: 200, listed });
|
||||
try {
|
||||
const reap = f.run('reap');
|
||||
expect(reap.exitCode).toBe(0);
|
||||
expect(reap.stderr.toString()).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/);
|
||||
expect(reap.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them');
|
||||
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
|
||||
expect(destroyed).toHaveLength(1);
|
||||
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1');
|
||||
const up = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '1' }, timeout: 90_000 });
|
||||
expect(up.stderr.toString()).toContain('create refused before any request');
|
||||
expect(up.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them');
|
||||
expect(up.stderr.toString()).not.toMatch(/reaping[^\n]*(ubirun-other|ubirun-1000000000-cccc)/);
|
||||
expect(f.calls().split('\n').filter(l => l.includes('destroy'))).toHaveLength(1);
|
||||
for (const name of ['ubirun-other-1000000000-bbbb-t1', 'ubirun-1000000000-cccc-t1']) expect(readFileSync(join(f.root, 'listed'), 'utf8')).toContain(name);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=a project with no VMs (Ubicloud answers vm list with a bare newline) reads as empty everywhere vm_rows feeds; fails_when=a blank list row becomes a VM named "-" again and reap dies on it or usage asks to show it; why_new=every fake list in this file had at least one VM, so #3678 shipped green; seam=none; no_claim=the real API's empty-body shape (taken from #3678's reading of ubi_cli.rb)
|
||||
test('an empty project (vm list answers a bare or blank line) has no VMs: reap destroys nothing and exits 0, usage prints total 0 0, list --mine prints nothing (#3678)', () => {
|
||||
for (const listed of ['\n', ' \n']) {
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('Writing Style preamble section', () => {
|
||||
const fenceLine = out.split('\n').find(l => l.includes('gstack-skill-start" --skill'));
|
||||
expect(fenceLine).toBeDefined();
|
||||
expect(fenceLine).not.toMatch(/~\/\.claude\//);
|
||||
expect(fenceLine).toContain('$GSTACK_BIN');
|
||||
expect(fenceLine).toContain('$GSTACK_ROOT/bin/');
|
||||
});
|
||||
|
||||
test('tier 1 preamble does NOT include Writing Style section', () => {
|
||||
|
||||
Reference in new issue
Block a user