mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
feat: crew PRs carry their brief's Plan line; ship-queue ready and land check it (GST-1751) (#4118)
* feat: crew PRs carry their brief's Plan line; ship-queue ready and land check it (GST-1751) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat: /ship finish and train ready/land check the Plan line (GST-1751) finish --plan resolves the brief's Plan line before stage 1; train ready, base-red ready, train land and stamp land read and write the READY note's plan record like direct ready and land. Review fixes: grant and exception rows never pass as plan approvals, render --plan stays short when redacted, no explicit changelog-only exemption, off mode still records a resolved line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: bin/gstack-approvals:56:functional-contract reader=qa Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: cover the plan check at ready, train ready, land-direct, finish and the policy key (GST-1751) Review-Finding: p1/specialist-batched#3 reader=testing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: the Plan line stands for the gate's real state, and a broken store never throws (GST-1751) A row id from an autoplan run resolves to that run's newest gate revision; a run id needs the plan approval answered and no decision left unanswered. readStore skips malformed rows and unreadable files; with the key off the check never throws or prints. Train land rechecks the Plan line before its merge on every path. /ship's Step 19-C renders Approvals with --plan; DRIVER.md back under its size limit. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: train land refuses a slot with no plan record under require (GST-1751) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Step 8 plan discovery (lib/ship-audit.ts and the plan-completion sections) skips the Plan record line, so a later Plan: <path> binding is still found; render --plan refuses a line it cannot parse. The review skill's pins drop by 4 bytes. Review-Finding: p1/red-team#1 reader=red-team Review-Finding: scripts/resolvers/plan-gates.ts:206:functional-contract reader=qa Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment passes the prose lint (GST-1751) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: captain.md describes the READY PLAN: line (GST-1751) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: plan discovery splits the Plan line on whitespace and skips record kinds in any case (GST-1751) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: lower the gstack-review size pins after the Step 8 binding shrank 4 bytes (GST-1751) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
fd0e3390e9
commit
901f246d74
44 files changed
+870
-46
No files matched your search
@@ -227,6 +227,7 @@ describe('import --autoplan', () => {
|
||||
const rows = importAutoplan(run, 'b', 'Garry');
|
||||
if ('code' in rows) throw new Error(rows.message);
|
||||
expect(rows.map(r => [r.by, r.words])).toEqual([['Garry, relayed by agent:GSTA-36', 'Do it'], ["agent:GSTA-36, its own decision (not the owner's words)", 'Accepted on the standing approval']]);
|
||||
expect(rows.map(r => r.words_by ?? null)).toEqual([null, 'agent:GSTA-36']);
|
||||
});
|
||||
|
||||
test('a default run id whose YYYYMMDDHHMMSS stamp passes Luhn still imports (capy-captain-flow flake)', () => {
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('/captain skill', () => {
|
||||
for (const h of ['## Lane discipline', '## Plan before build', '## Contributions from outside the crew']) expect(doc).toContain(h);
|
||||
expect(doc).toMatch(/## Plan before build[^]*?reports `APPROVED`[^]*?## Contributions from outside the crew/);
|
||||
const brief = read('captain/templates/brief.md');
|
||||
expect(brief).toContain('Plan: required | approved: <autoplan run dir, or its checkpoint dir> | exempt: <reason');
|
||||
expect(brief).toContain('Plan: required | approved: <autoplan run id or approval row id> | exempt: <reason');
|
||||
expect(brief).toContain('gstack-autoplan answer --out <run dir> --gate-rev <n> --reply <tokens>');
|
||||
expect(brief).toMatch(/^1\. Plan[^]*?^2\. Build after the word[^]*?^3\. Ship[^]*?^Done when:/m);
|
||||
expect(brief).toContain('gstack-approvals import --autoplan');
|
||||
|
||||
+1
-1
@@ -660,7 +660,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
|
||||
it. Content changes during or after verification restart at
|
||||
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
|
||||
|
||||
**One call for stages 1–4:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
|
||||
**One call for stages 1–4:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
|
||||
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
|
||||
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
|
||||
|
||||
|
||||
+1
-1
@@ -659,7 +659,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
|
||||
it. Content changes during or after verification restart at
|
||||
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
|
||||
|
||||
**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
|
||||
**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
|
||||
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
|
||||
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
|
||||
|
||||
|
||||
+1
-1
@@ -662,7 +662,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
|
||||
it. Content changes during or after verification restart at
|
||||
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
|
||||
|
||||
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
|
||||
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
|
||||
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
|
||||
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
|
||||
|
||||
|
||||
+1
-1
@@ -664,7 +664,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
|
||||
it. Content changes during or after verification restart at
|
||||
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
|
||||
|
||||
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
|
||||
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
|
||||
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
|
||||
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
|
||||
|
||||
|
||||
+9
-9
@@ -6,52 +6,52 @@
|
||||
"agy/gstack-plan-design-review": 109100,
|
||||
"agy/gstack-plan-devex-review": 114928,
|
||||
"agy/gstack-plan-eng-review": 143581,
|
||||
"agy/gstack-review": 104752,
|
||||
"agy/gstack-review": 104744,
|
||||
"codex/gstack-autoplan": 108344,
|
||||
"codex/gstack-plan-design-review": 110457,
|
||||
"codex/gstack-plan-devex-review": 116436,
|
||||
"codex/gstack-plan-eng-review": 144746,
|
||||
"codex/gstack-review": 104275,
|
||||
"codex/gstack-review": 104267,
|
||||
"copilot/gstack-autoplan": 109044,
|
||||
"copilot/gstack-plan-design-review": 109366,
|
||||
"copilot/gstack-plan-devex-review": 114390,
|
||||
"copilot/gstack-plan-eng-review": 143746,
|
||||
"copilot/gstack-review": 104333,
|
||||
"copilot/gstack-review": 104325,
|
||||
"cursor/gstack-autoplan": 108276,
|
||||
"cursor/gstack-plan-design-review": 107908,
|
||||
"cursor/gstack-plan-devex-review": 113660,
|
||||
"cursor/gstack-plan-eng-review": 142336,
|
||||
"cursor/gstack-review": 122805,
|
||||
"cursor/gstack-review": 122797,
|
||||
"factory/gstack-autoplan": 110554,
|
||||
"factory/gstack-plan-design-review": 111006,
|
||||
"factory/gstack-plan-devex-review": 117023,
|
||||
"factory/gstack-plan-eng-review": 145444,
|
||||
"factory/gstack-review": 126578,
|
||||
"factory/gstack-review": 126570,
|
||||
"gbrain/gstack-autoplan": 108683,
|
||||
"gbrain/gstack-plan-eng-review": 124963,
|
||||
"gemini/gstack-autoplan": 108834,
|
||||
"gemini/gstack-plan-design-review": 109194,
|
||||
"gemini/gstack-plan-devex-review": 114218,
|
||||
"gemini/gstack-plan-eng-review": 143579,
|
||||
"gemini/gstack-review": 104155,
|
||||
"gemini/gstack-review": 104147,
|
||||
"hermes/gstack-autoplan": 108637,
|
||||
"hermes/gstack-plan-eng-review": 124999,
|
||||
"kiro/gstack-autoplan": 108050,
|
||||
"kiro/gstack-plan-design-review": 107754,
|
||||
"kiro/gstack-plan-devex-review": 113512,
|
||||
"kiro/gstack-plan-eng-review": 142198,
|
||||
"kiro/gstack-review": 122629,
|
||||
"kiro/gstack-review": 122621,
|
||||
"openclaw/gstack-autoplan": 108506,
|
||||
"openclaw/gstack-plan-eng-review": 123478,
|
||||
"opencode/gstack-autoplan": 109025,
|
||||
"opencode/gstack-plan-design-review": 109222,
|
||||
"opencode/gstack-plan-devex-review": 114268,
|
||||
"opencode/gstack-plan-eng-review": 143605,
|
||||
"opencode/gstack-review": 123427,
|
||||
"opencode/gstack-review": 123419,
|
||||
"slate/gstack-autoplan": 108163,
|
||||
"slate/gstack-plan-design-review": 107831,
|
||||
"slate/gstack-plan-devex-review": 113586,
|
||||
"slate/gstack-plan-eng-review": 142267,
|
||||
"slate/gstack-review": 122717
|
||||
"slate/gstack-review": 122709
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, STAMP_PLACEHOLDERS_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy';
|
||||
import { PLAN_RECORD_KEY_VERSION } from '../lib/ship-policy-keys';
|
||||
import { fillDue } from '../lib/ship-queue-train';
|
||||
import { cmpVersion, parseVersion } from '../lib/version-source';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
@@ -40,6 +41,10 @@ describe('validatePolicy (schema + containment, pure)', () => {
|
||||
const earlier = REVIEW_SCOPE_KEY_VERSION.replace(/(\d+)\.0$/, (_, p) => `${Number(p) - 7}.0`);
|
||||
expect(codes({ review_scope: 'all', min_gstack_version: earlier })).toEqual(['POLICY_INVALID:review_scope']);
|
||||
expect(codes({ review_scope: 'frozen' })).toEqual([]);
|
||||
// Value: protects=plan_record accepts only off|warn|require; fails_when=the enum guard is removed or loosened; why_new=no plan_record row; seam=none; no_claim=ready/land enforcement
|
||||
expect(codes({ plan_record: 'strict' })).toContain('POLICY_INVALID:plan_record');
|
||||
expect(codes({ plan_record: true })).toContain('POLICY_INVALID:plan_record');
|
||||
for (const v of ['off', 'warn', 'require']) expect(codes({ plan_record: v, min_gstack_version: PLAN_RECORD_KEY_VERSION })).toEqual([]);
|
||||
expect(codes([])).toEqual(['POLICY_INVALID:']);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* The plan-record check (lib/plan-record.ts, GST-1751): the PR body's Plan line, resolved against every branch's
|
||||
* approval rows, the exemptions docs/captain.md lists, the warn and require modes, and land's READY-note check.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { buildRow, locate, type ApprovalRow } from '../lib/approval-record';
|
||||
import { exemptReason, finishPlan, landPlan, parsePlanLine, planMode, readyPlan, resolvePlan } from '../lib/plan-record';
|
||||
import { planBinding } from '../lib/ship-audit';
|
||||
import { loadReady, persist, repoIdentity } from '../lib/ship-queue-train-record';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const dirs: string[] = [];
|
||||
const tmp = (prefix: string) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); dirs.push(d); return d; };
|
||||
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
|
||||
|
||||
function fixture(files: Record<string, Array<Partial<ApprovalRow> & { ask: string; source?: string; outcome?: string; at?: string }>> = {}) {
|
||||
const cwd = tmp('plan-repo-');
|
||||
spawnSync('git', ['init', '-q', '-b', 'capy/feature'], { cwd, timeout: 30_000 });
|
||||
const state = tmp('plan-state-');
|
||||
const env = { GSTACK_STATE_ROOT: state, GSTACK_CAPY: '', GSTACK_APPROVALS_DIR: '', GSTACK_PLAN_RECORD: '' } as NodeJS.ProcessEnv;
|
||||
const loc = locate({ cwd, env, branch: 'probe' });
|
||||
if ('code' in loc) throw new Error(loc.message);
|
||||
const dir = path.dirname(loc.file);
|
||||
if (Object.keys(files).length) fs.mkdirSync(dir, { recursive: true });
|
||||
const ids: Record<string, string> = {};
|
||||
for (const [file, rows] of Object.entries(files)) {
|
||||
const lines = rows.map(r => {
|
||||
const built = buildRow({ ask: r.ask, words: r.words ?? 'do it', by: r.by ?? 'Garry (via GStack Minder)', scope: r.scope ?? 'the reviewed plan', outcome: r.outcome, source: r.source, pending: r.pending, date: '2026-11-07' }, file, new Date(r.at ?? '2026-11-07T00:00:00Z'));
|
||||
if ('code' in built) throw new Error(built.message);
|
||||
const row = r.words_by ? { ...built, words_by: r.words_by } : built;
|
||||
ids[r.ask] = row.id;
|
||||
return JSON.stringify(row);
|
||||
});
|
||||
fs.writeFileSync(path.join(dir, `${file}.jsonl`), lines.join('\n') + '\n');
|
||||
}
|
||||
return { cwd, env, ids };
|
||||
}
|
||||
|
||||
describe('parsePlanLine', () => {
|
||||
test('reads approved, exempt and required, tolerating spacing and bold, and ignores fenced text', () => {
|
||||
expect(parsePlanLine('x\nPlan: approved: ap-20261107055041\n')).toEqual({ kind: 'approved', line: 'Plan: approved: ap-20261107055041', token: 'ap-20261107055041' });
|
||||
expect(parsePlanLine('**Plan:** approved ~/.capy/work/run/ap')).toMatchObject({ kind: 'approved', token: '~/.capy/work/run/ap' });
|
||||
expect(parsePlanLine('Plan: exempt: bug fix (R65)')).toMatchObject({ kind: 'exempt', reason: 'bug fix (R65)' });
|
||||
expect(parsePlanLine('Plan: required')).toMatchObject({ kind: 'required' });
|
||||
expect(parsePlanLine('```\nPlan: exempt: flaky\n```\nno line here')).toBeNull();
|
||||
expect(parsePlanLine('| Plan: approved: x |')).toBeNull();
|
||||
expect(parsePlanLine('Plan: /home/u/plan.md\nPlan: approved: ap-1')).toMatchObject({ kind: 'approved', token: 'ap-1' });
|
||||
});
|
||||
|
||||
test('exemptions are the docs/captain.md list or crew:<rule>, with a trailing parenthetical ignored', () => {
|
||||
expect(exemptReason('bug fix (R65)')).toBe('bug fix');
|
||||
expect(exemptReason('Flaky')).toBe('flaky');
|
||||
expect(exemptReason('crew: rule 31')).toBe('crew:rule 31');
|
||||
expect(exemptReason('light plan')).toBeNull();
|
||||
expect(exemptReason('feature')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planMode', () => {
|
||||
test('base policy first, then the crew opt-in, then warn in a Capy session, else off', () => {
|
||||
expect(planMode('require', { GSTACK_CAPY: '1' }).mode).toBe('require');
|
||||
expect(planMode('off', { GSTACK_PLAN_RECORD: 'require' })).toEqual({ mode: 'require', source: 'GSTACK_PLAN_RECORD' });
|
||||
expect(planMode('off', { GSTACK_CAPY: '1' })).toEqual({ mode: 'warn', source: 'Capy crew default' });
|
||||
expect(planMode(undefined, {}).mode).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePlan', () => {
|
||||
test('a run id resolves from another branch file, and the newest gate revision decides', () => {
|
||||
const f = fixture({
|
||||
main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }],
|
||||
'other-lane': [{ ask: 'Approve the plan B rev1', source: 'autoplan:ap-2@gate_rev=1' }, { ask: 'Approve the plan B rev2', source: 'autoplan:ap-2@gate_rev=2', outcome: 'pending' }],
|
||||
});
|
||||
const a = resolvePlan(parsePlanLine('Plan: approved: ap-1'), f);
|
||||
expect(a.ok && a.record).toMatchObject({ state: 'approved', ref: 'ap-1', row_id: f.ids['Approve the plan A'], resolver: 1 });
|
||||
const b = resolvePlan(parsePlanLine('Plan: approved: ap-2'), f);
|
||||
expect(!b.ok && b.failure.code).toBe('PLAN_NOT_APPROVED');
|
||||
});
|
||||
|
||||
test("a gate answered in an agent's own words never counts, structured or legacy", () => {
|
||||
const f = fixture({ x: [
|
||||
{ ask: 'own words', source: 'autoplan:ap-3@gate_rev=1', words_by: 'agent:GSTA-1' },
|
||||
{ ask: 'legacy', source: 'autoplan:ap-4@gate_rev=1', by: "agent:jam_x, its own decision (not the owner's words)" },
|
||||
] });
|
||||
for (const run of ['ap-3', 'ap-4']) {
|
||||
const r = resolvePlan(parsePlanLine(`Plan: approved: ${run}`), f);
|
||||
expect(!r.ok && r.failure.code).toBe('PLAN_NOT_APPROVED');
|
||||
expect(!r.ok && r.failure.message).toContain("agent's own words");
|
||||
}
|
||||
});
|
||||
|
||||
test('a row id resolves an approved plan row, never a ruling or grant row', () => {
|
||||
const f = fixture({ y: [{ ask: 'R41 A1: cut a skill' }, { ask: 'review_loop_grant abc for #1', scope: 'one fix-check' }, { ask: 'Grant for #2', scope: 'one review_loop_exception at abc' }] });
|
||||
const ok = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['R41 A1: cut a skill']}`), f);
|
||||
expect(ok.ok).toBe(true);
|
||||
const grant = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['review_loop_grant abc for #1']}`), f);
|
||||
expect(!grant.ok && grant.failure.code).toBe('PLAN_UNRESOLVED');
|
||||
const exception = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['Grant for #2']}`), f);
|
||||
expect(!exception.ok && exception.failure.code).toBe('PLAN_UNRESOLVED');
|
||||
expect(resolvePlan(parsePlanLine('Plan: approved: a-000000000000'), f)).toMatchObject({ ok: false, failure: { code: 'PLAN_UNRESOLVED' } });
|
||||
});
|
||||
|
||||
test('a run directory path resolves through its run.json', () => {
|
||||
const f = fixture({ z: [{ ask: 'Approve the plan C', source: 'autoplan:ap-5@gate_rev=1' }] });
|
||||
const run = tmp('plan-run-');
|
||||
fs.writeFileSync(path.join(run, 'run.json'), JSON.stringify({ run: 'ap-5' }));
|
||||
const r = resolvePlan(parsePlanLine(`Plan: approved: ${run}`), f);
|
||||
expect(r.ok && r.record.ref).toBe('ap-5');
|
||||
});
|
||||
|
||||
test('missing, required, unknown exemption, no store, and the changelog-only diff', () => {
|
||||
const f = fixture({ z: [{ ask: 'something' }] });
|
||||
expect(resolvePlan(null, f)).toMatchObject({ ok: false, failure: { code: 'PLAN_RECORD_MISSING' } });
|
||||
expect(resolvePlan(parsePlanLine('Plan: required'), f)).toMatchObject({ ok: false, failure: { code: 'PLAN_NOT_APPROVED' } });
|
||||
expect(resolvePlan(parsePlanLine('Plan: exempt: light plan'), f)).toMatchObject({ ok: false, failure: { code: 'PLAN_EXEMPT_UNKNOWN' } });
|
||||
expect(resolvePlan(parsePlanLine('Plan: exempt: bug fix (R65)'), f)).toMatchObject({ ok: true, record: { state: 'exempt', reason: 'bug fix' } });
|
||||
expect(resolvePlan(parsePlanLine('Plan: approved: ap-1'), fixture())).toMatchObject({ ok: false, failure: { code: 'PLAN_STORE_UNAVAILABLE' } });
|
||||
expect(resolvePlan(null, { ...f, changed: ['changelog.d/a.md', 'changelog.d/b.md'] })).toMatchObject({ ok: true, record: { reason: 'changelog-only' } });
|
||||
expect(resolvePlan(null, { ...f, changed: ['changelog.d/a.md', 'lib/x.ts'] })).toMatchObject({ ok: false, failure: { code: 'PLAN_RECORD_MISSING' } });
|
||||
expect(resolvePlan(parsePlanLine('Plan: exempt: changelog-only'), { ...f, changed: ['lib/x.ts'] })).toMatchObject({ ok: false, failure: { code: 'PLAN_EXEMPT_UNKNOWN' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ready and land', () => {
|
||||
test('ready warns in warn mode, refuses in require mode, and records a resolved line', () => {
|
||||
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
|
||||
const base = { cwd: f.cwd, changed: ['lib/x.ts'] };
|
||||
expect(readyPlan({ ...base, env: f.env, body: '', policyValue: 'off' })).toEqual({});
|
||||
expect(readyPlan({ ...base, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'off' })).toMatchObject({ record: { ref: 'ap-1' } });
|
||||
expect(readyPlan({ ...base, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'off' }).line).toBeUndefined();
|
||||
const warn = readyPlan({ ...base, env: { ...f.env, GSTACK_CAPY: '1' }, body: '', policyValue: 'off' });
|
||||
expect(warn.failure).toBeUndefined();
|
||||
expect(warn.line).toContain('PLAN: warn');
|
||||
expect(warn.line).toContain('PLAN_RECORD_MISSING');
|
||||
const refused = readyPlan({ ...base, env: f.env, body: '', policyValue: 'require' });
|
||||
expect(refused.failure?.code).toBe('PLAN_RECORD_MISSING');
|
||||
const ok = readyPlan({ ...base, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'require' });
|
||||
expect(ok.record).toMatchObject({ line: 'Plan: approved: ap-1', state: 'approved', ref: 'ap-1' });
|
||||
});
|
||||
|
||||
test('land needs the READY note to carry the PR body\'s current Plan line under require, and never reads the store', () => {
|
||||
const record = { line: 'Plan: exempt: bug fix (R65)', state: 'exempt' as const, reason: 'bug fix', resolver: 1 };
|
||||
expect(landPlan({ env: {}, policyValue: 'off', body: '' })).toEqual({});
|
||||
expect(landPlan({ env: {}, policyValue: 'require', body: '' }).failure?.code).toBe('QUEUE_PLAN_MISMATCH');
|
||||
expect(landPlan({ env: {}, policyValue: 'require', record, body: 'Plan: exempt: bug fix (R65)' })).toEqual({ line: 'PLAN: exempt bug fix (from the READY note)' });
|
||||
expect(landPlan({ env: {}, policyValue: 'require', record, body: 'Plan: exempt: flaky' }).failure?.code).toBe('QUEUE_PLAN_MISMATCH');
|
||||
const warn = landPlan({ env: { GSTACK_CAPY: '1' }, policyValue: 'off', body: '' });
|
||||
expect(warn.failure).toBeUndefined();
|
||||
expect(warn.line).toContain('PLAN: warn');
|
||||
const frag = { line: 'Plan: exempt: changelog-only', state: 'exempt' as const, reason: 'changelog-only', resolver: 1 };
|
||||
expect(landPlan({ env: {}, policyValue: 'require', record: frag, body: 'no line' }).failure).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-approvals plan-check and render --plan', () => {
|
||||
test('plan-check prints PLAN: and exits 0, or names the refusal; render --plan prints the line ready reads', () => {
|
||||
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
|
||||
const run = (args: string[]) => {
|
||||
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-approvals'), ...args], { cwd: f.cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...f.env } });
|
||||
return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
|
||||
};
|
||||
const ok = run(['plan-check', '--plan', 'approved: ap-1']);
|
||||
expect(ok.status).toBe(0);
|
||||
expect(ok.out).toContain('PLAN: approved ap-1');
|
||||
const bad = run(['plan-check', '--plan', 'Plan: exempt: light plan']);
|
||||
expect(bad.status).not.toBe(0);
|
||||
expect(bad.err).toContain('PLAN_EXEMPT_UNKNOWN');
|
||||
const body = run(['render', '--full', '--plan', 'Plan: approved: ap-1']).out;
|
||||
expect(parsePlanLine(body)).toMatchObject({ kind: 'approved', token: 'ap-1' });
|
||||
expect(body).toContain('Plan check: approved ap-1 row=');
|
||||
expect(body).not.toContain('Approve the plan A');
|
||||
});
|
||||
|
||||
test('the Plan record line is never Step 8\'s plan-file binding, and render --plan refuses a line it cannot parse', () => {
|
||||
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
|
||||
const run = (args: string[]) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-approvals'), ...args], { cwd: f.cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...f.env } });
|
||||
const body = `${run(['render', '--plan', 'Plan: approved: ap-1']).stdout}\nPlan: docs/designs/X.md\n`;
|
||||
expect(planBinding(body)).toBe('docs/designs/X.md');
|
||||
const sed = fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'plan-completion.md'), 'utf8').split('\n').find(l => l.startsWith('_BOUND=$(gh pr view'))!;
|
||||
const pipeline = sed.replace(/^_BOUND=\$\(gh pr view --json body -q \.body 2>\/dev\/null/, 'cat').replace(/\)$/, '');
|
||||
expect(spawnSync('bash', ['-c', pipeline], { input: body, encoding: 'utf8', timeout: 30_000 }).stdout.trim()).toBe('docs/designs/X.md');
|
||||
const typo = run(['render', '--plan', 'aproved: ap-1']);
|
||||
expect(typo.status).not.toBe(0);
|
||||
expect(typo.stdout).toBe('');
|
||||
expect(typo.stderr).toContain('render --plan needs');
|
||||
});
|
||||
|
||||
const bound = (body: string) => {
|
||||
const sed = fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'plan-completion.md'), 'utf8').split('\n').find(l => l.startsWith('_BOUND=$(gh pr view'))!;
|
||||
const pipeline = sed.replace(/^_BOUND=\$\(gh pr view --json body -q \.body 2>\/dev\/null/, 'cat').replace(/\)$/, '');
|
||||
return { ts: planBinding(body), sh: spawnSync('bash', ['-c', pipeline], { input: body, encoding: 'utf8', timeout: 30_000 }).stdout.trim() };
|
||||
};
|
||||
|
||||
test('a plan file named like a record kind is a binding, not a record line', () => {
|
||||
for (const file of ['approved-plan.md', 'required.md', 'docs/designs/exempt.md']) {
|
||||
expect(parsePlanLine(`Plan: ${file}`)).toBeNull();
|
||||
expect(bound(`Plan: approved: ap-1\nPlan: ${file}\n`)).toEqual({ ts: file, sh: file });
|
||||
}
|
||||
});
|
||||
|
||||
test('a tab-separated binding resolves without the tab, and record kinds are skipped in any case', () => {
|
||||
expect(bound('Plan:\tdocs/designs/X.md\n')).toEqual({ ts: 'docs/designs/X.md', sh: 'docs/designs/X.md' });
|
||||
expect(bound('Plan:\tAPPROVED:\tap-1\nPlan: Required\nPlan:\n\tdocs/designs/Y.md\nPlan:\tdocs/designs/X.md\n')).toEqual({ ts: 'docs/designs/X.md', sh: 'docs/designs/X.md' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('/ship finish and the READY note', () => {
|
||||
test('finish --plan refuses before any stage under require, and a READY note round-trips the record land reads', () => {
|
||||
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
|
||||
expect(finishPlan(f.cwd, 'main', undefined, f.env)).toEqual({});
|
||||
expect(finishPlan(f.cwd, 'main', undefined, { ...f.env, GSTACK_PLAN_RECORD: 'require' }).failure?.code).toBe('PLAN_RECORD_MISSING');
|
||||
const ok = finishPlan(f.cwd, 'main', 'approved: ap-1', { ...f.env, GSTACK_PLAN_RECORD: 'require' });
|
||||
expect(ok.failure).toBeUndefined();
|
||||
expect(ok.line).toContain('PLAN: approved ap-1');
|
||||
const state = tmp('plan-notes-');
|
||||
const prev = process.env.GSTACK_STATE_ROOT;
|
||||
process.env.GSTACK_STATE_ROOT = state;
|
||||
try {
|
||||
const keys = repoIdentity(f.cwd);
|
||||
const record = readyPlan({ cwd: f.cwd, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'require', changed: ['lib/x.ts'] }).record!;
|
||||
persist(f.cwd, [{ schema: 1, kind: 'ready', repo_key: keys.key, pr: '7', head: 'a'.repeat(40), base_sha: 'b'.repeat(40), ready_at: 'now', screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: 'now', plan: record }], { push: false });
|
||||
const note = loadReady(keys.keys, '7', 'a'.repeat(40));
|
||||
expect(note?.plan).toEqual(record);
|
||||
expect(landPlan({ env: {}, policyValue: 'require', record: note?.plan, body: 'Plan: approved: ap-1' }).failure).toBeUndefined();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the gate state a Plan line stands for (review blockers, GST-1751)', () => {
|
||||
test('a row id from an older gate revision does not outlive a rejected newer revision', () => {
|
||||
const f = fixture({ main: [{ ask: 'Approve the plan C rev1', source: 'autoplan:ap-3@gate_rev=1' }, { ask: 'Approve the plan C rev2', source: 'autoplan:ap-3@gate_rev=2', outcome: 'rejected', at: '2026-11-07T02:00:00Z' }] });
|
||||
const r = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['Approve the plan C rev1']}`), f);
|
||||
expect(!r.ok && r.failure.code).toBe('PLAN_NOT_APPROVED');
|
||||
expect(!r.ok && r.failure.message).toContain('rejected');
|
||||
});
|
||||
|
||||
test('a run id needs the plan approval answered and no decision left unanswered, whichever answer event came last', () => {
|
||||
const half = fixture({ main: [{ ask: 'Approve the plan D', source: 'autoplan:ap-4@gate_rev=1', pending: '1 decision(s) unanswered: P2' }] });
|
||||
const h = resolvePlan(parsePlanLine('Plan: approved: ap-4'), half);
|
||||
expect(!h.ok && h.failure.code).toBe('PLAN_NOT_APPROVED');
|
||||
expect(!h.ok && h.failure.message).toContain('unanswered');
|
||||
const done = fixture({ main: [
|
||||
{ ask: 'Approve the plan D', source: 'autoplan:ap-4@gate_rev=1', pending: '1 decision(s) unanswered: P2' },
|
||||
{ ask: 'Approve the plan D (P2 only)', source: 'autoplan:ap-4@gate_rev=1', outcome: 'pending', pending: 'the plan approval itself', at: '2026-11-07T03:00:00Z' },
|
||||
] });
|
||||
const d = resolvePlan(parsePlanLine('Plan: approved: ap-4'), done);
|
||||
expect(d.ok && d.record).toMatchObject({ state: 'approved', ref: 'ap-4', row_id: done.ids['Approve the plan D'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a broken approval store never throws, and changes nothing with the key off', () => {
|
||||
function broken() {
|
||||
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
|
||||
const loc = locate({ cwd: f.cwd, env: f.env, branch: 'probe' });
|
||||
if ('code' in loc) throw new Error(loc.message);
|
||||
const dir = path.dirname(loc.file);
|
||||
fs.appendFileSync(path.join(dir, 'main.jsonl'), JSON.stringify({ schema_version: 1, id: 'a-000000000000', ask: 'no source' }) + '\n{torn\n');
|
||||
fs.mkdirSync(path.join(dir, 'unreadable.jsonl'));
|
||||
return f;
|
||||
}
|
||||
|
||||
test('key off: ready and finish give the same output as with no line at all, and nothing throws', () => {
|
||||
const f = broken();
|
||||
const body = 'Plan: approved: ap-1';
|
||||
expect(readyPlan({ cwd: f.cwd, env: f.env, body: '', changed: ['lib/x.ts'] })).toEqual({});
|
||||
const off = readyPlan({ cwd: f.cwd, env: f.env, body, changed: ['lib/x.ts'] });
|
||||
expect(off.line).toBeUndefined();
|
||||
expect(off.failure).toBeUndefined();
|
||||
const fin = finishPlan(f.cwd, 'main', body, f.env);
|
||||
expect(fin.line).toBeUndefined();
|
||||
expect(fin.failure).toBeUndefined();
|
||||
expect(landPlan({ env: f.env, body, record: undefined })).toEqual({});
|
||||
});
|
||||
|
||||
test('warn and require read past the broken rows and still resolve; an unreadable store is a refusal, never a throw', () => {
|
||||
const f = broken();
|
||||
const warn = readyPlan({ cwd: f.cwd, env: { ...f.env, GSTACK_CAPY: '1' }, body: 'Plan: approved: ap-1', changed: ['lib/x.ts'] });
|
||||
expect(warn.line).toContain('PLAN: approved ap-1');
|
||||
const gone = fixture();
|
||||
const req = readyPlan({ cwd: gone.cwd, env: { ...gone.env, GSTACK_PLAN_RECORD: 'require' }, body: 'Plan: approved: ap-1', changed: ['lib/x.ts'] });
|
||||
expect(req.failure?.code).toBe('PLAN_STORE_UNAVAILABLE');
|
||||
});
|
||||
});
|
||||
@@ -536,6 +536,39 @@ describe('land --pin: refusals (nothing merged)', () => {
|
||||
expect(r.err).toContain('gstack-ship-queue ready --pr 16');
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=under GSTACK_PLAN_RECORD=require land refuses a READY note with no plan or another line than the PR body, lands a matching one, and a merged rerun skips the check; fails_when=landPlan's wiring in landDirect is dropped or runs after the merge; why_new=plan-record.test.ts covers landPlan only; seam=none; no_claim=ready's resolution (the note is written directly)
|
||||
test('QUEUE_PLAN_MISMATCH under GSTACK_PLAN_RECORD=require: no plan on the READY note or a stale line refuses and merges nothing; a matching note lands; a merged rerun skips the check', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '26');
|
||||
const body = shippedBody('26', pin) + '\nPlan: exempt: bug fix\n';
|
||||
fx.write(d => { d.prs['26'].body = body; });
|
||||
const req = (extra: string[] = []) => runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '26', '--pin', pin, '--base', 'main', ...extra], { ...fx.env, GSTACK_PLAN_RECORD: 'require' });
|
||||
const bare = req();
|
||||
refused(fx, bare, 'QUEUE_PLAN_MISMATCH');
|
||||
expect(bare.err).toContain('carries no plan record');
|
||||
const notePlan = (line: string) => {
|
||||
const prior = process.env.GSTACK_STATE_ROOT;
|
||||
process.env.GSTACK_STATE_ROOT = path.join(fx.repo.root, 'ready-state');
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
persist(fx.repo.work, [{ schema: 1, kind: 'ready', repo_key: repoIdentity(fx.repo.work).key, pr: '26', head: pin, base_sha: fx.repo.git('rev-parse', 'origin/main'), ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now, plan: { line, state: 'exempt', reason: line.replace('Plan: exempt: ', ''), resolver: 1 } } as any]);
|
||||
} finally { if (prior === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prior; }
|
||||
};
|
||||
notePlan('Plan: exempt: docs only');
|
||||
const stale = req();
|
||||
refused(fx, stale, 'QUEUE_PLAN_MISMATCH');
|
||||
expect(stale.err).toContain('but the PR body now says "Plan: exempt: bug fix"');
|
||||
notePlan('Plan: exempt: bug fix');
|
||||
const ok = req();
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain('PLAN: exempt bug fix (from the READY note)');
|
||||
fx.write(d => { d.prs['26'].body = shippedBody('26', pin); });
|
||||
const again = req();
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(again.out).toContain('already merged');
|
||||
expect(again.out).not.toContain('QUEUE_PLAN_MISMATCH');
|
||||
}, 120_000);
|
||||
|
||||
test('QUEUE_LAND_CHECKS: a red check, a pending check, and a required check GitHub never scheduled', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '17');
|
||||
|
||||
@@ -65,6 +65,20 @@ describe('stamped trains: build and land', () => {
|
||||
void h2;
|
||||
}, 300_000);
|
||||
|
||||
// Value: protects=under GSTACK_PLAN_RECORD=require train land refuses a slot whose READY receipt has no plan record before pushing a stamp; fails_when=slotPlan's wiring in landUpdate is dropped or runs after the push; why_new=ship-queue-train tests cover train ready only, not train land; seam=none; no_claim=landDirect/stamp-commit slotPlan paths and a matching-plan land (train-built receipts carry no plan)
|
||||
test('under GSTACK_PLAN_RECORD=require train land refuses a READY receipt with no plan record and pushes nothing', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const before = fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1');
|
||||
const refused = queue(fx, ['train', 'land', '--train', nonce, '--pr', '1', '--no-wait'], { GSTACK_PLAN_RECORD: 'require' });
|
||||
expect(refused.status).toBe(EXIT.refused);
|
||||
expect(refused.err).toContain('(QUEUE_PLAN_MISMATCH)');
|
||||
expect(refused.out).toContain('PLAN: refused the READY note carries no plan record');
|
||||
expect(refused.out).not.toMatch(/^PUSHED: /m);
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(before);
|
||||
expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' });
|
||||
}, 300_000);
|
||||
|
||||
test('a foreign push to the PR branch is never overwritten; a merge outside the train freezes the queue', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
|
||||
@@ -104,6 +104,29 @@ describe('the policy gate', () => {
|
||||
expect(ok.out).toMatch(new RegExp(`^READY_RECEIPT: .*ship-queue/ready/[^/]+/1-${head}\\.json \\(notes pushed to origin refs/notes/gstack-trains\\)$`, 'm'));
|
||||
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe('');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=under plan_record require ready refuses a PR body without a Plan line and records no READY receipt; a resolvable line lands in the receipt; fails_when=readyPlan's refusal or the receipt's plan field is dropped; why_new=plan-record.test.ts covers the helper only, not ready's wiring; seam=none; no_claim=approved-row resolution against a real store (exempt line used)
|
||||
test('under GSTACK_PLAN_RECORD=require ready refuses a body with no Plan line and writes no receipt; an exempt line is recorded on the receipt', () => {
|
||||
const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' });
|
||||
const head = openPr(fx, '1');
|
||||
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(fx.repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const req = { GSTACK_PLAN_RECORD: 'require' };
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate], req);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(line(missing.out, 'PLAN:')).toContain('(PLAN_RECORD_MISSING)');
|
||||
expect(missing.err).toContain('PLAN_RECORD_MISSING');
|
||||
expect(missing.out).not.toContain('READY_RECEIPT:');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
setPrBody(fx, '1', shippedBody('1', head) + '\nPlan: exempt: bug fix\n');
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate], req);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(line(ok.out, 'PLAN:')).toBe('PLAN: exempt bug fix');
|
||||
const receipt = path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`);
|
||||
expect(JSON.parse(fs.readFileSync(receipt, 'utf8')).plan).toMatchObject({ line: 'Plan: exempt: bug fix', state: 'exempt', reason: 'bug fix' });
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('ready under a train policy: the screen choice', () => {
|
||||
@@ -135,6 +158,22 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=train ready resolves the Plan line once (one PLAN: line), refuses under require with no receipt, and records the plan train land reads; fails_when=trainPlan's refusal or receipt field is dropped, or both ready paths print PLAN:; why_new=no train-ready plan case; seam=none; no_claim=train land's slotPlan
|
||||
test('under GSTACK_PLAN_RECORD=require train ready refuses a body with no Plan line and writes nothing; an exempt line prints one PLAN: line and lands on the receipt', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const req = { GSTACK_PLAN_RECORD: 'require' };
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen'], req);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.err).toContain('(PLAN_RECORD_MISSING)');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
setPrBody(fx, '1', shippedBody('1', head) + '\nPlan: exempt: bug fix\n');
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen'], req);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out.split('\n').filter(l => l.startsWith('PLAN:'))).toEqual(['PLAN: exempt bug fix']);
|
||||
const receipt = path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`);
|
||||
expect(JSON.parse(fs.readFileSync(receipt, 'utf8')).plan).toMatchObject({ line: 'Plan: exempt: bug fix', state: 'exempt', reason: 'bug fix' });
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a lane whose origin is another URL form of the repository (#3331's git.capy.ai) writes its receipt under the owner/repo key", () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
githubOrigin(fx, 'https://git.capy.ai/fixture/repo.git');
|
||||
|
||||
@@ -172,6 +172,26 @@ describe('finish (Steps 15-16)', () => {
|
||||
expect(f.git('ls-remote', '--heads', 'origin', 'feat').stdout).toBe('');
|
||||
});
|
||||
|
||||
// Value: protects=finish --plan resolves the brief's Plan line before any stage: require + Plan: required stops with no commit; a resolvable line prints PLAN: first; fails_when=finishPlan runs after the stages, its stop is dropped, or the line is not first; why_new=plan-record.test.ts covers the helper only; seam=none; no_claim=approved-row resolution (exempt line used)
|
||||
test('finish --plan: under GSTACK_PLAN_RECORD=require "Plan: required" stops before any stage; a resolvable line under warn prints PLAN: first', () => {
|
||||
const f = fx();
|
||||
const reviewed = wtree(f);
|
||||
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
|
||||
const post = snapshot(f);
|
||||
const args = ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE];
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const stop = f.run(RELEASE, [...args, '--plan', 'Plan: required'], { GSTACK_PLAN_RECORD: 'require' });
|
||||
expect(stop.status).toBe(3);
|
||||
expect(stop.stdout).toMatch(/^PLAN: refused .*\(PLAN_NOT_APPROVED\)/m);
|
||||
expect(stop.stdout).toMatch(/^STOP: .*\(PLAN_NOT_APPROVED\); fix: /m);
|
||||
expect(stop.stdout).not.toContain('COMMIT:');
|
||||
expect(f.git('rev-parse', 'HEAD').stdout.trim()).toBe(head);
|
||||
const ok = f.run(RELEASE, [...args, '--plan', 'exempt: bug fix'], { GSTACK_PLAN_RECORD: 'warn' });
|
||||
expect(ok.stdout.split('\n')[0]).toBe('PLAN: exempt bug fix');
|
||||
expect(ok.stdout).toMatch(/^COMMIT: /m);
|
||||
expect(ok.status).toBe(0);
|
||||
});
|
||||
|
||||
// Value: protects=under changelog_fragments the branch's own fragment is release metadata: version writes nothing and names it in NEXT, finish commits it as the release commit and the route counts it as metadata, and another fragment is still a feature file; fails_when=finish stops on changelog.d/<slug>.md or routes it to re-review (/ship #3648 fix-check 4, CRITICAL);
|
||||
// why_new=releasePaths predates changelog_fragments (#3654); seam=gstack-ship-policy show-effective, gstack-wtree; no_claim=the merge step's fold
|
||||
test('under changelog_fragments the branch fragment is release metadata and Step 12 names it', () => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 48,
|
||||
"why": "GST-1751: Step 8 plan discovery skips the Plan record line (case-insensitive, whitespace split); Step 16 names finish --plan"
|
||||
}
|
||||
Reference in new issue
Block a user