mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
141 lines
9.4 KiB
TypeScript
141 lines
9.4 KiB
TypeScript
import {afterAll, beforeAll, expect, test} from 'bun:test';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import {spawnSync} from 'node:child_process';
|
||
import {getQuestion} from '../scripts/question-registry';
|
||
import {E2E_TOUCHFILES} from './helpers/touchfiles-data';
|
||
import {CARVE_GUARDS} from './helpers/carve-guards';
|
||
const root=path.resolve(import.meta.dir,'..');
|
||
const temp=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-ceo-mode-preference-'));
|
||
function section(s:string){
|
||
const match=s.match(/^### 0[A-Z]\. Mode Selection\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
|
||
if(!match)throw new Error('CEO Mode Selection section missing');
|
||
return match[1]!;
|
||
}
|
||
const rendered=new Map<string,string>();
|
||
const env=(state:string)=>({...process.env,GSTACK_HOME:state,GSTACK_STATE_ROOT:state});
|
||
beforeAll(()=>{
|
||
for(const host of ['claude','codex']){
|
||
const out=path.join(temp,host),state=path.join(temp,'render-state');
|
||
const result=spawnSync(process.execPath,['run','scripts/gen-skill-docs.ts','--host',host,'--out-dir',out],{cwd:root,env:env(state),encoding:'utf8',timeout:120_000});
|
||
if(result.status!==0)throw new Error(result.stderr||result.stdout);
|
||
rendered.set(host,fs.readFileSync(path.join(out,host==='claude'?'plan-ceo-review':'.agents/skills/gstack-plan-ceo-review','SKILL.md'),'utf8'));
|
||
}
|
||
},120_000);
|
||
afterAll(()=>fs.rmSync(temp,{recursive:true,force:true}));
|
||
function modeId(document:string){
|
||
const ids=[...section(document).matchAll(/`question_id=([^`]+)`/g)].map(m=>m[1]!);
|
||
expect(ids).toHaveLength(1);return ids[0]!;
|
||
}
|
||
function tuning(document:string){
|
||
return document.split('## Question Tuning (skip entirely if')[1]!.split('\n## ')[0]!;
|
||
}
|
||
function renderedCheck(host:string,id:string){
|
||
const match=tuning(rendered.get(host)!).match(/`(printf '%s' "<question summary>" \| ([^`]+)\/gstack-question-preference --check "<id>" --summary-stdin)`/)!;
|
||
expect(match).not.toBeNull();
|
||
expect(match[2]).toBe(host==='claude'?'~/.claude/skills/gstack/bin':'$GSTACK_BIN');
|
||
const quote=(value:string)=>"'"+value.replace(/'/g,"'\\''")+"'";
|
||
// Run the rendered command, substituting its documented fields and mapping
|
||
// the host's installed executable location to this isolated checkout.
|
||
return match[1]!.replace('<question summary>','Select the CEO review mode for the current plan.')
|
||
.replace('"<id>"',quote(id))
|
||
.replace(match[2]!+'/gstack-question-preference',quote(path.join(root,'bin/gstack-question-preference')));
|
||
}
|
||
function checkWithPreference(host:string,preference?:string,writeId?:string){
|
||
const id=modeId(rendered.get(host)!),state=fs.mkdtempSync(path.join(temp,'state-'));
|
||
const run=(args:string[],input?:string)=>spawnSync(path.join(root,'bin/gstack-question-preference'),args,{cwd:root,env:env(state),input,encoding:'utf8',timeout:30_000});
|
||
if(preference){const written=run(['--write',JSON.stringify({question_id:writeId??id,preference,source:'plan-tune'})]);expect(written.status).toBe(0);}
|
||
const result=spawnSync('bash',['-c',renderedCheck(host,id)],{cwd:root,env:env(state),encoding:'utf8',timeout:30_000});
|
||
expect(result.status).toBe(0);return {id,result,run};
|
||
}
|
||
test('source and both isolated host renders bind the shared check, marker and log to the registered mode identity',()=>{
|
||
const source=fs.readFileSync(path.join(root,'plan-ceo-review/SKILL.md.tmpl'),'utf8');
|
||
for(const document of [source,...rendered.values()]){
|
||
const id=modeId(document),s=section(document);
|
||
expect(getQuestion(id)).toMatchObject({id:'plan-ceo-review-mode',skill:'plan-ceo-review',category:'routing',door_type:'two-way'});
|
||
const routing=s.slice(s.indexOf('3. Resolve that recommendation'),s.indexOf('4. **Mode handoff:**')).replace(/\s+/g,' ');
|
||
expect(routing).toContain('check `question_id=plan-ceo-review-mode` through the preamble');
|
||
expect(routing).toContain('A check that exits 0 with `AUTO_DECIDE` selects the recommendation');
|
||
expect(routing).toContain('go to the automatic handoff in step 4');
|
||
expect(routing).toContain('When tuning is false, omit the lookup');
|
||
const handoff=s.split('**Mode handoff:**')[1]!;
|
||
expect(handoff).toContain('`'+id+': AUTO_DECIDE`');
|
||
expect(handoff).toContain('Auto-decided review mode → <selected mode> (your preference)');
|
||
const asked=routing.split('Without that successful check,')[1]!;
|
||
expect(asked).toContain('offer all four modes in one AskUserQuestion');
|
||
expect(asked).toContain('**STOP for the answer**');
|
||
expect(asked).toContain('When `QUESTION_TUNING: true`');
|
||
expect(asked).toContain('`<gstack-qid:'+id+'>`');
|
||
const logging=handoff.split('Record mode provenance after the handoff')[1]!.split('If 0D')[0]!.replace(/\s+/g,' ');
|
||
expect(logging).toContain('no question log because none was asked');
|
||
expect(logging).toContain('`'+id+'`, `auto_decided: true`');
|
||
expect(logging).toContain('`auto_decided: false`, including the question ID only when `QUESTION_TUNING: true`');
|
||
expect(s.indexOf('3. Resolve that recommendation')).toBeGreaterThanOrEqual(0);
|
||
expect(s.indexOf('4. **Mode handoff:**')).toBeGreaterThan(s.indexOf('3. Resolve that recommendation'));
|
||
expect(handoff).toContain('After selection');
|
||
expect(handoff).toContain('send brief chat before tools or further questions');
|
||
expect(s.slice(0,s.indexOf('4. **Mode handoff:**'))).not.toMatch(/\blog (?:with|that ID)\b/);
|
||
expect(handoff.indexOf('Record mode provenance after the handoff')).toBeGreaterThan(handoff.indexOf('- Other selections:'));
|
||
expect(handoff.indexOf("Follow the selected mode's route:")).toBeGreaterThan(handoff.indexOf('Record mode provenance after the handoff'));
|
||
expect(s).not.toContain('plan-ceo-review-mode-selection');
|
||
}
|
||
for(const document of rendered.values()){
|
||
expect(tuning(document)).toContain('<gstack-qid:{question_id}>');
|
||
expect(tuning(document)).toContain('"question_id":"<id>"');
|
||
}
|
||
});
|
||
test('both rendered canonical checks actually honor a stored never-ask preference',()=>{
|
||
for(const host of rendered.keys()){
|
||
const {result,run}=checkWithPreference(host,'never-ask');
|
||
expect(result.stdout.trim()).toBe('AUTO_DECIDE');
|
||
const wrong=run(['--check','plan-ceo-review-mode-selection','--summary-stdin'],'Select the CEO review mode.');
|
||
expect(wrong.status).toBe(0);expect(wrong.stdout.trim()).toBe('ASK_NORMALLY');
|
||
}
|
||
});
|
||
test('absent, always-ask and foreign preferences do not authorize either host to select a mode',()=>{
|
||
for(const host of rendered.keys()){
|
||
for(const [preference,id] of [[undefined,undefined],['always-ask',undefined],['never-ask','plan-design-review-mode']] as const){
|
||
expect(checkWithPreference(host,preference,id).result.stdout.trim()).toBe('ASK_NORMALLY');
|
||
}
|
||
}
|
||
});
|
||
test('only an explicit user selection or enabled successful mode check bypasses asking',()=>{
|
||
for(const document of rendered.values()){
|
||
const s=section(document),q=tuning(document);
|
||
expect(s).toContain('An explicit choice skips steps 2–3');
|
||
const routing=s.slice(s.indexOf('3. Resolve that recommendation'),s.indexOf('4. **Mode handoff:**')).replace(/\s+/g,' ');
|
||
expect(routing).toContain('When `QUESTION_TUNING: true`, first check `question_id=plan-ceo-review-mode` through the preamble');
|
||
expect(routing).toContain('When tuning is false, omit the lookup');
|
||
expect(routing).toContain('A check that exits 0 with `AUTO_DECIDE` selects the recommendation');
|
||
expect(s).toContain('**STOP for the answer**');
|
||
expect(document).toContain('Question Tuning (skip entirely if `QUESTION_TUNING: false`)');
|
||
expect(q).toContain('`AUTO_DECIDE` means choose the recommended option');
|
||
expect(q).toContain('Auto-decided [summary] → [option] (your preference). Change with /plan-tune.');
|
||
expect(q).toContain('`ASK_NORMALLY` means ask.');
|
||
expect(s).toContain('Selecting a mode does not approve changes');
|
||
expect(document.replace(/\s+/g,' ')).toContain('With no required choice, or after those choices settle, go to 0E');
|
||
expect(s.replace(/\s+/g,' ')).toContain('Preserve 0D approvals and ask about each proposed addition or cut');
|
||
expect(s).toContain('offer all four modes in one AskUserQuestion');
|
||
expect(s).toContain("using step 2's recommendation");
|
||
expect(s).toContain('For >15 planned changed files, recommend SCOPE REDUCTION');
|
||
expect(document).toContain('more than 8 files or more than 2 new classes/services');
|
||
expect(s.replace(/\s+/g,' ')).toContain('ask about each proposed addition or cut, including those prompted by file-count thresholds');
|
||
expect(s.replace(/\s+/g,' ')).toContain('Count distinct planned file additions, edits and deletions');
|
||
expect(s.replace(/\s+/g,' ')).toContain('mark estimated counts as estimates');
|
||
expect(s).toContain('These modes differ in kind, not coverage; do NOT score completeness');
|
||
expect(document).toContain('Note: options differ in kind, not coverage — no completeness score.');
|
||
}
|
||
});
|
||
test('the new render/runtime regression belongs to the existing auto-decide owner',()=>{
|
||
expect(Object.entries(E2E_TOUCHFILES).filter(([,v])=>v.includes('test/ceo-mode-preference-al.test.ts')).map(([k])=>k)).toEqual(['auto-decide-preserved']);
|
||
});
|
||
test('rendered mode contract stays within the existing canonical skeleton cap',()=>{
|
||
// --out-dir changes section-link roots only. Undo that output-location
|
||
// substitution before measuring the same canonical bytes as parity-suite.
|
||
const out=path.join(temp,'claude').replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
||
const canonical=rendered.get('claude')!.replace(new RegExp(out+'/([^\\s)`"\'*]+/sections/)','g'),(_m,p1)=>'~/.claude/skills/gstack/'+p1);
|
||
const cap=Object.values(CARVE_GUARDS).find(g=>g.skill==='plan-ceo-review')!.maxSkeletonBytes;
|
||
expect(Buffer.byteLength(canonical)).toBeLessThanOrEqual(cap);
|
||
});
|