mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
B3 parseFences: the body group is optional (lazy), so an LF/CRLF empty fence is zero
findings in both the canonical and record grammars; opener without closer still
RESULT_FINDINGS_MISSING.
B6 plan-reality/3: item/section references are not quantities; backtick phrases are
exempt only from the plan-scope rule-phrase check (B-f); a measured number inside
backticks stays flagged in review scope. vaguePlanTerms() feeds an advisory
`flagged: PLAN_REALITY ...` line under the p1 approval item (never blocking).
B7 canonical rows: absolute repo paths become repo-relative; snapshot/active-plan
citations become the plan file name with the source line mapped (or dropped).
B8 `status` prints `PENDING: <id> phase=<p> voice=<v> result=<file>` per open attempt.
B9 `gstack-gate render --all`; paged output says to relay every page.
B10 `gstack-plan-reality check --plan <snapshot>`: a number in a "quoted" span copied
verbatim from the reviewed plan needs no receipt (reviewer scope only).
B11 submit defaults the provider to host-subagent when --runner is omitted.
Lane B files: lib/autoplan-reconcile.ts lib/plan-reality.ts lib/autoplan-gate.ts
lib/gate-list.ts lib/autoplan-plan-text.ts lib/autoplan-run.ts bin/gstack-gate
bin/gstack-plan-reality scripts/resolvers/plan-reality.ts autoplan/sections/ceo-phase.md
autoplan/sections/eng-phase.md test/plan-reality.test.ts test/autoplan-gate-artifacts.test.ts
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
140 lines
10 KiB
TypeScript
140 lines
10 KiB
TypeScript
/**
|
|
* Next wave, Lane B artifacts (GBRA-40 items 7, 11, 14, 15, 18):
|
|
* B3 an empty gstack-findings fence (LF, CRLF, whitespace-only) is zero findings, not a missing fence;
|
|
* an opener without a closer and invalid rows are still refused.
|
|
* B7 canonical rows carry repo-relative paths; snapshot citations become the plan file and its source line.
|
|
* B8 `status` lists every open attempt with its result file.
|
|
* B9 `gstack-gate render --all` prints every item; paged output tells the relay to send every page.
|
|
* B6 a plan naming a minimum effect with no number gets a flagged PLAN_REALITY line under the plan approval item.
|
|
* B11 an outside voice submitted without --runner is labeled host-subagent in the consensus header.
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { normalizeFindingFile } from '../lib/autoplan-plan-text';
|
|
import { parseRecordFindings, parseReviewResult } from '../lib/autoplan-reconcile';
|
|
import { renderGate, type GateList } from '../lib/gate-list';
|
|
import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-gate-artifacts-'));
|
|
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
const SHA = 'a'.repeat(64);
|
|
const GATE = path.join(ROOT, 'bin', 'gstack-gate');
|
|
|
|
describe('B3: empty findings fence', () => {
|
|
const head = `INPUT: ceo ${SHA}\n\n## review\n\n`;
|
|
test('LF, CRLF and whitespace-only empty fences parse as zero findings in both grammars', () => {
|
|
for (const text of [`${head}\`\`\`gstack-findings\n\`\`\`\n`, `${head}\`\`\`gstack-findings\n\`\`\`\n`.replace(/\n/g, '\r\n'), `${head}\`\`\`gstack-findings\n \n\`\`\`\n`]) {
|
|
const r = parseReviewResult(text, { phase: 'ceo', sha256: SHA });
|
|
expect(r.fenceFound).toBe(true);
|
|
expect(r.errors).toEqual([]);
|
|
expect(r.findings).toEqual([]);
|
|
expect(parseRecordFindings(text)).toEqual({ findings: [], source: 'fence', errors: [] });
|
|
}
|
|
});
|
|
test('an opener without a closer is no fence; an invalid row is refused; two fences still parse', () => {
|
|
expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"x"}\n`, { phase: 'ceo', sha256: SHA }).errors).toContain('no ```gstack-findings fence');
|
|
expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"Bogus","title":"x"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA }).errors[0]).toMatch(/severity "Bogus"/);
|
|
const two = parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"a"}\n\`\`\`\n\n\`\`\`gstack-findings\n\`\`\`\n\n\`\`\`gstack-findings\n{"severity":"Low","title":"b"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA });
|
|
expect(two.errors).toEqual([]);
|
|
expect(two.findings.map(f => f.title)).toEqual(['a', 'b']);
|
|
});
|
|
});
|
|
|
|
describe('B7: repo-relative paths and plan lines', () => {
|
|
test('absolute repo paths become relative; snapshot and active-plan citations map to the plan source line or drop the line', () => {
|
|
const repo = path.join(TMP, 'repo');
|
|
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
|
|
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
|
const source = path.join(TMP, 'plan-source.md');
|
|
fs.writeFileSync(source, '# Title\n\n## Implementation plan\n\nline A\nline B\nline C\n\n## Review record\n');
|
|
const snapDir = path.join(TMP, 'autoplan-ceo-Xy12');
|
|
fs.mkdirSync(snapDir, { recursive: true });
|
|
const snapshot = path.join(snapDir, 'ceo-implementation.md');
|
|
fs.writeFileSync(snapshot, '\nline A\nline B\nline C\n\n');
|
|
const active = path.join(TMP, 'plan-active.md');
|
|
fs.writeFileSync(active, `<!-- /autoplan restore point: "${TMP}/restore.md" -->\n${fs.readFileSync(source, 'utf8')}`);
|
|
const stray = path.join(TMP, 'other-snapshot.md');
|
|
fs.writeFileSync(stray, 'unrelated\n');
|
|
const ctx = { cwd: path.join(repo, 'src'), planFiles: [snapshot, active, stray], planSource: source, planName: 'plan.md' };
|
|
expect(normalizeFindingFile({ file: path.join(repo, 'src', 'worker.ts'), line: 9 }, ctx)).toEqual({ file: 'src/worker.ts', line: 9 });
|
|
expect(normalizeFindingFile({ file: snapshot, line: 3 }, ctx)).toEqual({ file: 'plan.md', line: 6 });
|
|
expect(normalizeFindingFile({ file: 'ceo-implementation.md', line: 2 }, ctx)).toEqual({ file: 'plan.md', line: 5 });
|
|
expect(normalizeFindingFile({ file: active, line: 6 }, ctx)).toEqual({ file: 'plan.md', line: 5 });
|
|
expect(normalizeFindingFile({ file: stray, line: 1 }, ctx)).toEqual({ file: 'plan.md' });
|
|
expect(normalizeFindingFile({ file: '/elsewhere/x.ts', line: 1 }, ctx)).toEqual({ file: '/elsewhere/x.ts', line: 1 });
|
|
expect(normalizeFindingFile({ file: 'lib/x.ts', line: 4 }, ctx)).toEqual({ file: 'lib/x.ts', line: 4 });
|
|
});
|
|
|
|
test('end to end: a reviewer citing the snapshot and an absolute repo path binds repo-relative rows; the bound copy stays verbatim', () => {
|
|
const out = startSmallRun(path.join(TMP, 'paths'));
|
|
expect(autoplan(['next', '--out', out]).status).toBe(0);
|
|
const snap = readRun(out).phases.ceo.snapshot_path as string;
|
|
const findings = JSON.stringify([{ severity: 'High', title: 'cap unexplained', file: snap, line: 2 }, { severity: 'Low', title: 'abs path', file: path.join(ROOT, 'lib', 'gate-list.ts'), line: 3 }]);
|
|
review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: findings });
|
|
const ids = attemptsOf(out, 'ceo');
|
|
const result = readRun(out).phases.ceo.native.attempts[0].result as string;
|
|
const bind = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', result, '--model', 'anthropic/claude-opus-5-5', '--attempt', ids.native]);
|
|
expect(bind.status, bind.stderr).toBe(0);
|
|
const output = readRun(out).phases.ceo.native.output as string;
|
|
const rows = fs.readFileSync(path.join(out, `${output}.findings.jsonl`), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
|
expect(rows.map((r: any) => [r.file, r.line])).toEqual([['plan.md', 5], ['lib/gate-list.ts', 3]]);
|
|
expect(fs.readFileSync(path.join(out, output), 'utf8')).toContain(snap);
|
|
});
|
|
});
|
|
|
|
describe('B8/B11: pending attempts in status; outside voice labeled host-subagent', () => {
|
|
test('status prints PENDING per open attempt; an outside submit without --runner reads model (host-subagent)', () => {
|
|
const out = startSmallRun(path.join(TMP, 'pending'));
|
|
expect(autoplan(['next', '--out', out]).status).toBe(0);
|
|
const ids = attemptsOf(out, 'ceo');
|
|
const run = readRun(out);
|
|
const status = autoplan(['status', '--out', out]);
|
|
expect(status.stdout).toContain(`PENDING: ${ids.native} phase=ceo voice=native result=${run.phases.ceo.native.attempts[0].result}`);
|
|
expect(status.stdout).toContain(`PENDING: ${ids.outside} phase=ceo voice=outside result=${run.phases.ceo.outside.attempts[0].result}`);
|
|
for (const voice of ['native', 'outside']) review(path.join(out, `ceo-${voice}-prompt.md`));
|
|
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5']).status).toBe(0);
|
|
expect(autoplan(['status', '--out', out]).stdout).not.toContain(`PENDING: ${ids.native}`);
|
|
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', run.phases.ceo.outside.attempts[0].result, '--model', 'openai/gpt-6.1-sol']).status).toBe(0);
|
|
expect(readRun(out).phases.ceo.outside.provider).toBe('host-subagent');
|
|
expect(fs.readFileSync(path.join(out, 'ceo-consensus.md'), 'utf8')).toContain('openai/gpt-6.1-sol(host-subagent)');
|
|
expect(autoplan(['status', '--out', out]).stdout).not.toContain('PENDING:');
|
|
});
|
|
});
|
|
|
|
describe('B9: every gate page reaches the owner', () => {
|
|
const item = (i: number) => ({ id: `d${i}`, title: `finding ${i}`, kind: 'auto' as const, recommended: 'a', options: [{ key: 'a', text: 'keep' }, { key: 'b', text: 'change' }] });
|
|
const list: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: [...Array.from({ length: 14 }, (_, i) => item(i + 1)), { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'a', options: [{ key: 'a', text: 'approve' }, { key: 'b', text: 'other' }] }] };
|
|
test('14 findings span three pages; --all prints every id once and paged output says to relay every page', () => {
|
|
expect(renderGate(list)).toContain('or every page with `--all`. Relay every page.');
|
|
const file = path.join(TMP, 'big-gate.json');
|
|
fs.writeFileSync(file, JSON.stringify(list));
|
|
const r = spawnSync('bun', [GATE, 'render', file, '--all'], { encoding: 'utf8', timeout: 30_000 });
|
|
expect(r.status, r.stderr).toBe(0);
|
|
expect(r.stdout).toContain('page=1/1');
|
|
for (const i of list.items) expect(r.stdout.match(new RegExp(`\\[${i.id}\\]`, 'g'))?.length).toBe(1);
|
|
const pages = [1, 2, 3].map(p => renderGate(list, p)).join('\n');
|
|
for (const i of list.items) expect(pages).toContain(`[${i.id}]`);
|
|
expect(spawnSync('bun', [GATE, 'render', file, '--all', '--page', '2'], { encoding: 'utf8', timeout: 30_000 }).status).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('B6: PLAN_REALITY flag beside the approval item', () => {
|
|
test('the plan names a minimum effect with no number: gate.json p1 carries the flag and the render prints it; nothing blocks', () => {
|
|
const dir = path.join(TMP, 'vague');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const plan = path.join(dir, 'plan.md');
|
|
fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover the cap', 'Ship once the minimum effect shows in the retry metrics. Tests cover the cap'));
|
|
const out = path.join(dir, 'run');
|
|
expect(autoplan(['next', '--out', out, '--plan', plan, '--no-ui']).status).toBe(0);
|
|
for (const phase of ['ceo', 'eng']) { const r = runPhase(out, phase); expect(r.outside.status, r.outside.stderr).toBe(0); }
|
|
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
|
|
const p1 = gate.items.find(i => i.id === 'p1')!;
|
|
expect(p1.flags).toEqual([expect.stringMatching(/^PLAN_REALITY "minimum effect" has no number, arm list or rule body \(plan\.md:6: .*minimum effect.*\) \(report only\)$/)]);
|
|
expect(renderGate(gate)).toMatch(/\n flagged: PLAN_REALITY "minimum effect"/);
|
|
expect(readRun(out).status).toBe('gate_pending');
|
|
});
|
|
});
|