mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
* Add failing tests: outside reviews read clean only from a last-line verdict
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Count an outside review as clean only from its last VERDICT line
Every outside-review prompt now asks the reviewer to end with exactly
VERDICT: CLEAN or VERDICT: FINDINGS, and the parser reads a pass only from
that line. Prose no-findings sentences are no longer evidence for clean.
Codex's structured review and /codex review run through codex exec, since
codex review --base takes no prompt and could never give the line.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Repin the outside reviewer briefs and fix the tests the verdict line broke (GST-1821)
The outside brief, both adversarial sections and the review-army prompt-file line now carry the last-line VERDICT request; their pins move under the preregistered no-loss replay GST-1821. Test fakes that wrote a clean reply now end it with VERDICT: CLEAN, the review carve marker follows the codex exec sentence, size pins drop where skills shrank, and the plan-review prompt-file line is shortened so /plan-eng-review stays at its pin.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Give the plan-review invocation test's fake reviewer the verdict line it now needs
CI's free-suite shard 5 failed at 1de52b0 with 18 cases in test/model-role-invocation.test.ts: the fake Codex reply had no VERDICT line, so it read unverified (exit 4), and the review-mode template now has one codex exec dispatch block instead of two. The fake reply now ends with VERDICT: FINDINGS and the block count expects one.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Stop the changelog correction from claiming the verdict line closes every false-clean case
Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Split the changelog correction's last sentence for readability
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Close the review fail-opens the fix-check found (GST-1821)
A /codex review whose git diff fails now stops before Codex runs instead of sending an empty diff. The verdict is read from the raw last line, so inline code after VERDICT: CLEAN no longer reads clean, and VERDICT: CLEAN beside numbered items with no severity reads unverified. Codex structured review gets the base-branch diff appended to its prompt, the way the Claude path does, so the scope no longer depends on the prompt the host writes.
Fix round granted by the Captain (a-aec3882c9d48). Review-section pins move under replay GST-1821: the free 681-input replay and the 54 scored probe replies are unchanged through the fixed parser.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Say in the changelog which numbered-list replies now read unverified, and that /codex review stops without its diff
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Say in the skills guide that /codex review now sends the diff through codex exec
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Check that the structured outside review appends the base-branch diff to its prompt
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>
226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
/** Execute each complete generated wrapper fence in its own fresh shell. */
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-code-skill-'));
|
|
const RENDER = path.join(DIR, 'render');
|
|
const REPO = path.join(DIR, 'repo');
|
|
const SCRATCH = path.join(DIR, 'scratch');
|
|
const RUNTIME = path.join(DIR, 'runtime " with $(touch NEVER)');
|
|
const BAD_RUNTIME = path.join(DIR, 'bad-runtime');
|
|
const PROMPT = path.join(DIR, "prompt ' with $(touch NEVER).txt");
|
|
const PROMPT_TEXT = 'Review literal text: $(touch NEVER) `touch NEVER` "\'\n';
|
|
const FAKE = path.join(DIR, 'claude.ts');
|
|
const CAPTURE = path.join(DIR, 'capture.json');
|
|
const q = (text: string) => `'${text.replaceAll("'", "'\\''")}'`;
|
|
type Mode = 'review' | 'challenge' | 'consult';
|
|
const fences = {} as Record<Mode, string>;
|
|
const ENV = { ...process.env, GSTACK_CLAUDE_BIN:process.execPath, GSTACK_CLAUDE_BIN_ARGS:JSON.stringify([FAKE]), CAPTURE,
|
|
CLAUDECODE:'', CODEX_THREAD_ID:'test-codex', CODEX_SANDBOX:'', GSTACK_ACTIVE_HOST:'codex',
|
|
GIT_AUTHOR_NAME:'Test', GIT_AUTHOR_EMAIL:'test@example.invalid', GIT_COMMITTER_NAME:'Test', GIT_COMMITTER_EMAIL:'test@example.invalid',
|
|
TMPDIR:SCRATCH,
|
|
};
|
|
|
|
function git(args: string[], cwd = REPO) {
|
|
const result = spawnSync('git', args, {cwd, env:ENV, encoding:'utf8', timeout:5000});
|
|
if (result.status !== 0) throw new Error(result.stderr);
|
|
}
|
|
|
|
beforeAll(() => {
|
|
for (const dir of [REPO, SCRATCH, path.join(BAD_RUNTIME, 'bin')]) fs.mkdirSync(dir, {recursive:true});
|
|
fs.symlinkSync(ROOT, RUNTIME, 'dir');
|
|
fs.symlinkSync(path.join(ROOT, 'lib'), path.join(BAD_RUNTIME, 'lib'), 'dir');
|
|
fs.writeFileSync(path.join(BAD_RUNTIME, 'bin/gstack-claude-code'), '#!/usr/bin/env bash\nprintf "%s\\n" "$RAW_CAPTURE"\n', {mode:0o755});
|
|
fs.writeFileSync(FAKE, `
|
|
import {writeFileSync} from 'node:fs';
|
|
const prompt = await Bun.stdin.text();
|
|
writeFileSync(process.env.CAPTURE!,JSON.stringify({args:process.argv.slice(2),prompt}));
|
|
if(process.env.FAKE_ERROR) {process.stdout.write('{broken');process.exit(0);}
|
|
console.log(JSON.stringify({result:process.env.FAKE_TEXT || '[P1] Review found a defect.',session_id:'consult-session',modelUsage:{'model-a':{},'model-b':{}}}));
|
|
`);
|
|
git(['init','-b','main']);
|
|
fs.writeFileSync(path.join(REPO, 'changed.txt'), 'baseline\n');
|
|
git(['add','changed.txt']);
|
|
git(['commit','-m','baseline']);
|
|
git(['remote','add','origin','.']);
|
|
git(['checkout','-b','work']);
|
|
fs.appendFileSync(path.join(REPO, 'changed.txt'), 'committed change\n');
|
|
git(['commit','-am','change']);
|
|
fs.appendFileSync(path.join(REPO, 'changed.txt'), 'working tree change\n');
|
|
|
|
// Output-only generation: no mutation of the checkout's active host renders.
|
|
const generated = spawnSync(process.execPath, ['run', 'scripts/gen-skill-docs.ts', '--host', 'codex', '--out-dir', RENDER], {
|
|
cwd:ROOT, encoding:'utf8', timeout:120000,
|
|
});
|
|
if (generated.status !== 0) throw new Error(generated.stderr);
|
|
const content = fs.readFileSync(path.join(RENDER, '.agents/skills/gstack-claude-code/SKILL.md'), 'utf8');
|
|
for (const mode of ['review','challenge','consult'] as const) {
|
|
const heading = `## ${mode[0].toUpperCase() + mode.slice(1)} mode`;
|
|
const start = content.indexOf(heading);
|
|
const end = content.indexOf('\n## ', start + heading.length);
|
|
if (start === -1) throw new Error(`Missing generated ${mode} section`);
|
|
const section = content.slice(start, end === -1 ? undefined : end);
|
|
const matches = [...section.matchAll(/```bash\n([\s\S]*?)\n```/g)];
|
|
if (matches.length !== 1) throw new Error(`${mode} must have exactly one executable fence, found ${matches.length}`);
|
|
fences[mode] = matches[0][1];
|
|
}
|
|
});
|
|
afterAll(() => fs.rmSync(DIR, {recursive:true, force:true}));
|
|
|
|
function shell(mode: Mode, extra: NodeJS.ProcessEnv = {}, options: {resume?: boolean; runtime?: string; cwd?: string; planReview?: boolean} = {}) {
|
|
fs.rmSync(CAPTURE, {force:true});
|
|
fs.writeFileSync(PROMPT, PROMPT_TEXT, {mode:0o600});
|
|
// These are precisely the literal substitutions the skill requests. Do not
|
|
// prepend setup, merge fences, or supply state from a prior shell invocation.
|
|
const script = fences[mode]
|
|
.replace("'<prepared-prompt-file>'", q(PROMPT))
|
|
.replace("'<gstack-runtime-root>'", q(options.runtime ?? RUNTIME))
|
|
.replace("'<base>'", q('main'))
|
|
.replace("'<fresh-or-resume>'", q(options.resume ? 'resume' : 'fresh'))
|
|
.replaceAll('"$CLAUDE_RUNNER" --user-invoked --cwd', `"$CLAUDE_RUNNER" --user-invoked${options.planReview ? ' --role plan-review' : ''} --cwd`);
|
|
return spawnSync('bash', ['-c',script], {cwd:options.cwd ?? REPO, env:{...ENV,...extra}, encoding:'utf8',timeout:10000});
|
|
}
|
|
function captured() { return JSON.parse(fs.readFileSync(CAPTURE,'utf8')); }
|
|
function expectTempsCleaned() {
|
|
expect(fs.existsSync(PROMPT)).toBe(false);
|
|
expect(fs.readdirSync(SCRATCH)).toEqual([]);
|
|
expect(fs.existsSync(path.join(REPO,'NEVER'))).toBe(false);
|
|
}
|
|
function saveSession(id: string) {
|
|
fs.mkdirSync(path.join(REPO,'.context'),{recursive:true});
|
|
fs.writeFileSync(path.join(REPO,'.context/claude-session-id'),id + '\n');
|
|
}
|
|
function savedSession() { return fs.readFileSync(path.join(REPO,'.context/claude-session-id'),'utf8'); }
|
|
|
|
describe('complete generated Claude Code wrapper modes', () => {
|
|
for (const mode of ['review','challenge','consult'] as const) {
|
|
test(`${mode} stale wrapper refuses and cleans its prompt before spawning`, () => {
|
|
const result = shell(mode,{CLAUDECODE:'1',CODEX_THREAD_ID:'',GSTACK_ACTIVE_HOST:'claude'});
|
|
expect(result.status).toBe(78);
|
|
expect(fs.existsSync(CAPTURE)).toBe(false);
|
|
expect(result.stderr).toContain('setup --host claude');
|
|
expectTempsCleaned();
|
|
});
|
|
}
|
|
for (const mode of ['review','challenge'] as const) {
|
|
test(`${mode} independently captures committed and working-tree context with no tools`, () => {
|
|
const result = shell(mode);
|
|
expect(result.status).toBe(0);
|
|
expect(captured().prompt).toStartWith(PROMPT_TEXT);
|
|
expect(captured().prompt).toContain('+committed change');
|
|
expect(captured().prompt).toContain('+working tree change');
|
|
const args = captured().args;
|
|
expect(args[args.indexOf('--tools') + 1]).toBe('');
|
|
expect(args).not.toContain('--resume');
|
|
expect(result.stdout).toContain('[P1] Review found a defect.');
|
|
expect(result.stdout).toContain('model-a');
|
|
expect(result.stdout).toContain('model-b');
|
|
expectTempsCleaned();
|
|
});
|
|
for (const response of ['I cannot review this request. NO_FINDINGS', 'Several observations without a severity.']) {
|
|
test(`${mode} rejects refusal or missing markers after a valid runner completion: ${response}`, () => {
|
|
const result = shell(mode, {FAKE_TEXT:response});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain('missing outside coverage');
|
|
expectTempsCleaned();
|
|
});
|
|
}
|
|
}
|
|
|
|
test('fresh and continued consult each run independently and preserve a literal session argument', () => {
|
|
const first = shell('consult', {FAKE_TEXT:'The configuration lives in settings.ts.'});
|
|
expect(first.status).toBe(0);
|
|
expect(captured().prompt).toBe(PROMPT_TEXT);
|
|
const args = captured().args;
|
|
expect(args[args.indexOf('--tools') + 1]).toBe('Read,Grep,Glob');
|
|
expect(args).not.toContain('--resume');
|
|
expect(savedSession()).toBe('consult-session\n');
|
|
expectTempsCleaned();
|
|
const previous = 'session " with spaces $(touch NEVER)';
|
|
saveSession(previous);
|
|
const continued = shell('consult', {}, {resume:true});
|
|
expect(continued.status).toBe(0);
|
|
const resumedArgs = captured().args;
|
|
expect(resumedArgs[resumedArgs.indexOf('--resume') + 1]).toBe(previous);
|
|
expect(savedSession()).toBe('consult-session\n');
|
|
expectTempsCleaned();
|
|
});
|
|
|
|
test('failed resumed invocation cleans captures without overwriting its previous session', () => {
|
|
saveSession('previous-session');
|
|
const result = shell('consult', {FAKE_ERROR:'1'}, {resume:true});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain('invalid-json');
|
|
expect(savedSession()).toBe('previous-session\n');
|
|
expectTempsCleaned();
|
|
});
|
|
|
|
test('complete mode fences reject malformed, false-success and empty runner captures', () => {
|
|
for (const raw of ['{broken','[]','{"status":"error","result":"NO_FINDINGS"}','{"status":"completed","is_error":true,"result":"NO_FINDINGS"}','{"status":"completed","result":" "}']) {
|
|
for (const mode of ['review','challenge','consult'] as const) {
|
|
saveSession('previous-session');
|
|
const result = shell(mode, {RAW_CAPTURE:raw}, {runtime:BAD_RUNTIME});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain('CLAUDE_CODE_ERROR');
|
|
expect(savedSession()).toBe('previous-session\n');
|
|
expectTempsCleaned();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('an explicit clean review preserves unknown model identity', () => {
|
|
const result = shell('review', {RAW_CAPTURE:'{"status":"completed","result":"NO_FINDINGS\\nVERDICT: CLEAN"}'}, {runtime:BAD_RUNTIME});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('NO_FINDINGS');
|
|
expect(result.stdout).toContain('Model: unknown');
|
|
expectTempsCleaned();
|
|
});
|
|
|
|
test('missing resume session stops before a provider call', () => {
|
|
fs.rmSync(path.join(REPO,'.context/claude-session-id'), {force:true});
|
|
const result = shell('consult', {}, {resume:true});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain('no saved Claude Code session');
|
|
expect(fs.existsSync(CAPTURE)).toBe(false);
|
|
expectTempsCleaned();
|
|
});
|
|
|
|
test('an empty branch diff stops before invoking Claude Code', () => {
|
|
const clean = path.join(DIR, 'clean-repo');
|
|
fs.mkdirSync(clean);
|
|
git(['init','-b','main'], clean);
|
|
fs.writeFileSync(path.join(clean, 'file.txt'), 'unchanged\n');
|
|
git(['add','file.txt'], clean);
|
|
git(['commit','-m','baseline'], clean);
|
|
for (const mode of ['review','challenge'] as const) {
|
|
const result = shell(mode, {}, {cwd:clean});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('Nothing to review');
|
|
expect(fs.existsSync(CAPTURE)).toBe(false);
|
|
expectTempsCleaned();
|
|
}
|
|
});
|
|
|
|
test('the documented plan-review consultation consumes a plan even without a repository diff', () => {
|
|
const clean = fs.mkdtempSync(path.join(DIR, 'plan-only-'));
|
|
git(['init', '-b', 'main'], clean);
|
|
fs.writeFileSync(path.join(clean, 'file.txt'), 'unchanged\n');
|
|
git(['add', 'file.txt'], clean);
|
|
git(['commit', '-m', 'baseline'], clean);
|
|
const result = shell('consult', { HOME: clean, CLAUDE_CONFIG_DIR: path.join(clean, '.claude'),
|
|
GSTACK_STATE_ROOT: path.join(clean, 'policy'), GSTACK_CLAUDE_MODEL: '', FAKE_TEXT: 'The plan needs an overflow check.' },
|
|
{ cwd: clean, planReview: true });
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('The plan needs an overflow check.');
|
|
const observed = JSON.parse(fs.readFileSync(CAPTURE, 'utf8'));
|
|
expect(observed.prompt).toBe(PROMPT_TEXT);
|
|
expect(observed.args).toContain('--model=claude-fable-5-1');
|
|
expect(result.stdout).not.toContain('Nothing to review');
|
|
expectTempsCleaned();
|
|
});
|
|
});
|