mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* review: /ship depth scales to risk, not size; two rounds is the cap (R51, GSTA-167) Non-risky code is light at any size; a risk class (money, auth, secret, security, user data) or gate code makes it full. UI or game text counts as docs. A fix-check counts as a round and round 2 never earns a third. The receipt records review_loop.rounds and review_depth.risk. Repins lib/review-depth.ts and pins lib/review-risk.ts under no-loss replay R51 (test/review-packets-removed.test.ts): the saved panel findings re-scored against this plan lose no blocker, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (round 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: UI text and SVGs stay code; a round-2 gate edit stops for the owner; SQL deletes match in any case (R51, Captain choice A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: restore review-policy.md tail; keep uppercase DELETE FROM (R51 repair, Captain grant) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: R51 fragment matches the final change Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy review-loop cap says two rounds (Step 14.5 audit) 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>
123 lines
8.0 KiB
TypeScript
123 lines
8.0 KiB
TypeScript
/**
|
|
* Free contracts of the native frozen-scope fixture (test/helpers/ship-frozen-scope-actor.ts):
|
|
* a scripted actor that follows Step 9.4 and the repeat decision passes the
|
|
* verdict with the real gstack-review-scope / gstack-review-log binaries, and
|
|
* the negative controls (editing the pre-existing file, leaving the diff defect
|
|
* in place, exiting after one pass) are named failures. No model call is made.
|
|
*/
|
|
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import {
|
|
createShipFrozenScopeFixture, FROZEN_SCOPE_DEFER_IDENTITY, FROZEN_SCOPE_FIX_IDENTITY, shipFrozenScopeFailures, shipFrozenScopeWorkflow,
|
|
} from './helpers/ship-frozen-scope-actor';
|
|
import { reviewLoopLine, summarizeReviewLoop } from '../lib/review-scope';
|
|
|
|
type Fixture = ReturnType<typeof createShipFrozenScopeFixture>;
|
|
let workflow = '';
|
|
const roots: string[] = [];
|
|
beforeAll(async () => { workflow = await shipFrozenScopeWorkflow(); }, 120_000);
|
|
afterAll(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); });
|
|
|
|
const SAFE = 'export const canReadInvoice = (owner: string, viewer: string) => owner === viewer;\n';
|
|
|
|
function fixture(): Fixture {
|
|
const f = createShipFrozenScopeFixture(workflow);
|
|
roots.push(f.root);
|
|
return f;
|
|
}
|
|
function sh(f: Fixture, command: string): string {
|
|
const r = spawnSync('bash', ['-c', command], { cwd: f.repo, encoding: 'utf8', env: { ...process.env, ...f.env }, timeout: 60_000 });
|
|
if (r.status !== 0) throw new Error(`${command}\n${r.stderr}${r.stdout}`);
|
|
return r.stdout;
|
|
}
|
|
async function hook(f: Fixture, tool: string, input: Record<string, unknown>): Promise<string | undefined> {
|
|
const out: any = await f.preToolUse({ hook_event_name: 'PreToolUse', tool_name: tool, tool_input: input } as any, undefined as any, {} as any);
|
|
return out.hookSpecificOutput.permissionDecision === 'allow' ? undefined : out.hookSpecificOutput.permissionDecisionReason;
|
|
}
|
|
/** A scripted actor: declared reads, classify, fix, defer, log, commit, repeat, log, continue. */
|
|
async function act(f: Fixture, options: { fix?: boolean; passes?: 1 | 2 } = {}) {
|
|
const { fix = true, passes = 2 } = options;
|
|
for (const file of [f.workflowPath, f.findingsPath, f.invoice, f.payments]) expect(await hook(f, 'Read', { file_path: file })).toBeUndefined();
|
|
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBeUndefined();
|
|
const classify = sh(f, f.commands.classify!);
|
|
expect(classify).toContain('REVIEW_SCOPE: policy=frozen pass=1 findings=2 fix=1 defer=1 note=0 blocking=2');
|
|
const classified = JSON.parse(fs.readFileSync(`${f.repo}/.gstack/tmp/review-classified-p1.json`, 'utf8'));
|
|
if (fix) {
|
|
expect(await hook(f, 'Write', { file_path: f.invoice, content: SAFE })).toBeUndefined();
|
|
fs.writeFileSync(f.invoice, SAFE);
|
|
}
|
|
expect(await hook(f, 'Bash', { command: f.commands.defer })).toBeUndefined();
|
|
expect(sh(f, f.commands.defer!)).toContain('DEFERRED: unfiled (dry run) CRITICAL payments.ts:2');
|
|
const findings = classified.findings.map((x: any) => ({ fingerprint: x.fingerprint, severity: x.severity, action: x.scope === 'fix' ? (fix ? 'auto-fixed' : 'skipped') : 'deferred',
|
|
...(x.scope === 'defer' ? { issue: null } : {}), locus: x.locus, kind: x.kind, blocking: x.blocking }));
|
|
const row = (extra: Record<string, unknown>) => ({ skill: 'review', timestamp: new Date().toISOString(), status: 'issues_found', issues_found: 0, critical: 0, informational: 0,
|
|
quality_score: 10.0, specialists: {}, findings, commit: sh(f, 'git rev-parse --short HEAD').trim(), via: 'ship', completed: true, converged: false, cycles: 1, pass: 1,
|
|
review_scope: 'frozen', range_base: null, ...extra });
|
|
const log = async (record: Record<string, unknown>) => {
|
|
expect(await hook(f, 'Write', { file_path: f.draft, content: JSON.stringify(record) })).toBeUndefined();
|
|
fs.writeFileSync(f.draft, JSON.stringify(record));
|
|
expect(await hook(f, 'Bash', { command: f.commands.persist })).toBeUndefined();
|
|
sh(f, f.commands.persist!);
|
|
};
|
|
await log(row({}));
|
|
if (passes === 1) {
|
|
expect(await hook(f, 'Bash', { command: f.commands.continue })).toBeUndefined();
|
|
sh(f, f.commands.continue!);
|
|
return;
|
|
}
|
|
expect(await hook(f, 'Bash', { command: f.commands.commit })).toBeUndefined();
|
|
sh(f, f.commands.commit!);
|
|
expect(await hook(f, 'Bash', { command: f.commands.repeat })).toBeUndefined();
|
|
const repeat = JSON.parse(sh(f, f.commands.repeat!));
|
|
expect(repeat).toMatchObject({ pass: 2, findings: [], coverage: 'completed' });
|
|
expect(await hook(f, 'Read', { file_path: `${f.repo}/.gstack/tmp/review-findings-p2.json` })).toBeUndefined();
|
|
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBeUndefined();
|
|
expect(sh(f, f.commands.classify!)).toContain('REVIEW_SCOPE: policy=frozen pass=2 findings=0 fix=0 defer=0 note=0 blocking=0');
|
|
await log(row({ status: 'clean', findings: [], converged: true, pass: 2, range_base: f.snapshot().head }));
|
|
expect(await hook(f, 'Bash', { command: f.commands.continue })).toBeUndefined();
|
|
sh(f, f.commands.continue!);
|
|
}
|
|
|
|
// The actor replays the bash workflow through bash -c and the shebang bins, so it is POSIX-only like the paid case it mirrors.
|
|
const posixTest = test.skipIf(process.platform === 'win32');
|
|
posixTest('a scripted actor that fixes the diff, defers the pre-existing defect and converges on pass 2 passes the verdict', async () => {
|
|
const f = fixture();
|
|
await act(f);
|
|
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
|
|
expect(verdict.failures).toEqual([]);
|
|
expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, rounds: 2, scope: 'frozen' });
|
|
const rows = verdict.evidence.rows as Array<Record<string, any>>;
|
|
expect(rows.map(r => r.pass)).toEqual([1, 2]);
|
|
expect(rows[0]!.findings.map((x: any) => [x.fingerprint, x.action])).toEqual([[FROZEN_SCOPE_FIX_IDENTITY, 'auto-fixed'], [FROZEN_SCOPE_DEFER_IDENTITY, 'deferred']]);
|
|
expect(rows[1]!.range_base).toBe(verdict.evidence.head);
|
|
expect(verdict.evidence.payments).toBe(verdict.evidence.originalPayments);
|
|
const summary = summarizeReviewLoop(rows, verdict.evidence.start.started_at);
|
|
expect(summary).toEqual(verdict.evidence.loop);
|
|
expect(reviewLoopLine(summary)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=0 fixed=1 scope=frozen');
|
|
}, 120_000);
|
|
|
|
posixTest('the fixture interface refuses the undeclared interactions before any decision', async () => {
|
|
const f = fixture();
|
|
expect(await hook(f, 'Edit', { file_path: f.payments, old_string: 'card', new_string: '?' })).toBe('Edit outside invoice.ts');
|
|
expect(await hook(f, 'Write', { file_path: f.payments, content: '' })).toBe('Write outside invoice.ts and the review record');
|
|
expect(await hook(f, 'Bash', { command: `${f.commands.classify} && echo done` })).toBe('Bash outside the fixture interface');
|
|
expect(await hook(f, 'Bash', { command: 'git status' })).toBe('Bash outside the fixture interface');
|
|
expect(await hook(f, 'Read', { file_path: `${f.root}/fixture.json` })).toBe('Read outside the declared files');
|
|
expect(await hook(f, 'AskUserQuestion', { questions: [] })).toBe('Undeclared tool');
|
|
expect(() => sh(f, f.commands.repeat!)).toThrow(/committed/);
|
|
expect(() => sh(f, f.commands.continue!)).toThrow(/logged/);
|
|
expect(f.snapshot().executions.filter(e => !e.allowed)).toHaveLength(6);
|
|
}, 120_000);
|
|
|
|
posixTest('leaving the diff defect in place or exiting after one pass are named failures', async () => {
|
|
const f = fixture();
|
|
await act(f, { fix: false, passes: 1 });
|
|
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
|
|
expect(verdict.failures).toContain('invoice.ts still accepts a viewer other than the owner');
|
|
expect(verdict.failures).toContain('expected exactly one repeat pass');
|
|
expect(verdict.failures).toContain('pass 1 did not record the in-diff defect as fixed');
|
|
expect(verdict.failures.some(line => line.startsWith('REVIEW_LOOP summary'))).toBe(true);
|
|
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBe('Loop decision already taken');
|
|
}, 120_000);
|