v1.91.7.0 feat: add functional QA and pre-publication docs checks (#2983)

* 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
This commit is contained in:
Garry Tan authored and GitHub committed 2026-09-29 06:07:35 -07:00
1 parent 65bfb0ce49
commit dcaea52800
333 files changed
+41755 -7357

No files matched your search

+184 -96
View File
@@ -1,6 +1,10 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { JUDGE_MS, CAPTURE_MS, CAPTURE_LONG_MS } from './helpers/eval-budgets';
import { runSkillTest, SESSION_DRAIN_GRACE_MS } from './helpers/session-runner';
import { registerBootstrapRetention } from './helpers/bootstrap-retention';
import { stopQaOnlyBrowser } from './helpers/qa-only-cleanup';
import { getProjectEvalDir } from './helpers/eval-store';
import { assertQaBrowserDeadline, assertQaBrowserPreparation, assertQaBrowserCheckpoints, qaDeadlineShellPolicy } from './helpers/qa-browser-deadline-evidence';
import { resolveEvalModel } from '../lib/eval-model';
import {
ROOT, browseBin, runId, evalsEnabled, selectedTests,
@@ -27,7 +31,7 @@ const browserSelected = evalsEnabled && (asideAvailable() || fs.existsSync(brows
// The skill's BROWSER SETUP decides Aside vs fallback; the prompt only tells the
// agent where the fallback binary is (the hermetic HOME has no global install).
const browserPrompt = (skillMd: string) =>
`Follow the BROWSER SETUP section in ${skillMd} exactly: it probes for Aside first and falls back to the gstack browse binary. If it falls back, the browse binary is at ${browseBin} (B="${browseBin}"; find-browse is shimmed under browse/bin in this directory). Do not look for any other browser.`;
`Follow the BROWSER SETUP section in ${skillMd} exactly: it probes for Aside first and falls back to the gstack browse binary. If it falls back, the browse binary is at ${browseBin} (B="${browseBin}"; find-browse is shimmed under browse/bin in this directory). Do not look for any other browser. The trusted deadline guard for this owned installation is ${path.join(ROOT, 'bin', 'gstack-qa-deadline')}; use that exact absolute path for G, not an ambient installation. Keep its deadline.json in the caller-owned qa-reports directory, and never replace or edit it directly.`;
// --- B4: QA skill E2E ---
@@ -37,7 +41,7 @@ describeIfSelected('QA skill E2E', ['qa-quick'], () => {
beforeAll(() => {
testServer = startTestServer();
qaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-qa-'));
qaDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'skill-e2e-qa-'));
setupBrowseShims(qaDir);
// Copy qa skill files into tmpDir
@@ -90,87 +94,153 @@ Write your report to ${qaDir}/qa-reports/qa-report.md`,
// --- QA-Only E2E (report-only, no fixes) ---
describeIfSelected('QA-Only skill E2E', ['qa-only-no-fix'], () => {
let qaOnlyDir: string;
let testServer: ReturnType<typeof startTestServer>;
beforeAll(() => {
testServer = startTestServer();
qaOnlyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-qa-only-'));
setupBrowseShims(qaOnlyDir);
// Copy qa-only skill files
copyDirSync(path.join(ROOT, 'qa-only'), path.join(qaOnlyDir, 'qa-only'));
// Copy qa templates (qa-only references qa/templates/qa-report-template.md)
fs.mkdirSync(path.join(qaOnlyDir, 'qa', 'templates'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'qa', 'templates', 'qa-report-template.md'),
path.join(qaOnlyDir, 'qa', 'templates', 'qa-report-template.md'),
);
// Init git repo (qa-only checks for feature branch in diff-aware mode)
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: qaOnlyDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(qaOnlyDir, 'index.html'), '<h1>Test</h1>\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
});
afterAll(() => {
try { fs.rmSync(qaOnlyDir, { recursive: true, force: true }); } catch {}
});
const finalizeMs = SESSION_DRAIN_GRACE_MS + 5_000;
let attempt = 0;
testConcurrentIfSelected('qa-only-no-fix', async () => {
const result = await runSkillTest({
prompt: `${browserPrompt('qa-only/SKILL.md')}
const started = Date.now();
const remainingWorkMs = () => {
const remaining = started + CAPTURE_MS - Date.now();
if (remaining <= 0) throw new Error('QA-only work budget exhausted');
return remaining;
};
const attemptRunId = `${process.env.EVALS_RUN_ID ?? runId}-qa-only-${process.pid}-${++attempt}`;
let qaOnlyDir: string | undefined;
let testServer: ReturnType<typeof startTestServer> | undefined;
let result: Awaited<ReturnType<typeof runSkillTest>> | undefined;
let passed = false;
let failure: unknown;
try {
qaOnlyDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'skill-e2e-qa-only-'));
testServer = startTestServer();
setupBrowseShims(qaOnlyDir);
copyDirSync(path.join(ROOT, 'qa-only'), path.join(qaOnlyDir, 'qa-only'));
copyDirSync(path.join(ROOT, 'qa', 'sections'), path.join(qaOnlyDir, 'qa', 'sections'));
copyDirSync(path.join(ROOT, 'qa', 'templates'), path.join(qaOnlyDir, 'qa', 'templates'));
const run = (cmd: string, args: string[]) => {
const command = spawnSync(cmd, args, { cwd: qaOnlyDir, stdio: 'pipe', timeout: 5000 });
if (command.error) throw command.error;
expect(command.status, command.stderr.toString()).toBe(0);
};
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.copyFileSync(path.join(ROOT, 'browse/test/fixtures/qa-only.html'), path.join(qaOnlyDir, 'index.html'));
fs.copyFileSync(path.join(ROOT, 'test/fixtures/qa-only-browser-probe.ts'), path.join(qaOnlyDir, 'fixture-browser-probe.ts'));
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
fs.mkdirSync(path.join(qaOnlyDir, 'qa-reports/screenshots'), { recursive: true });
const deadlinePolicy = qaDeadlineShellPolicy(qaOnlyDir, path.join(ROOT, 'bin/gstack-qa-deadline'), browseBin);
result = await runSkillTest({
prompt: `${browserPrompt('qa-only/SKILL.md')}
Read the file qa-only/SKILL.md for the QA-only workflow instructions.
The qa-only/ and qa/ directories here are the owned installed skill assets for this run, not product-directory substitutes. Resolve installed qa/gstack-qa section references (including ~/.claude/skills/gstack/qa/sections/<file>) to ${qaOnlyDir}/qa/sections/<file>, qa-only/gstack-qa-only section references to ${qaOnlyDir}/qa-only/sections/<file>, and qa template references to ${qaOnlyDir}/qa/templates/<file>. Do not discover or read an ambient skill installation.
Skip the preamble bash block, lake intro, telemetry, and contributor mode sections — go straight to the QA workflow.
Run a Quick QA test on ${testServer.url}/qa-eval.html
Run a Quick QA test on ${testServer.url}/qa-only.html
This fixture has one static homepage, one console error, no links or user journeys, and no test framework. Its index.html is an exact copy of the served page. Scope this run to homepage load and console health; do not expand into navigation, form, visual-design or framework-discovery audits.
Write one concise charter and a concise report, retaining required evidence, scores and untested-coverage fields. Use short factual summaries and evidence IDs instead of repeating full commands or the charter in multiple sections.
Use the supplied read-only browser probe for the baseline and any replay, as this child command after the guard's --: bash -c 'bun "${path.join(qaOnlyDir, 'fixture-browser-probe.ts')}" "${browseBin}" "${testServer.url}/qa-only.html" "${path.join(qaOnlyDir, 'qa-reports/screenshots/initial.png')}"'
Run it inside the deadline guard. It navigates, reads console errors and saves the screenshot, returning one JSON object with actual command outputs and exit codes. Preserve that decoded object in checkpoints. This case tests report-only authority and provenance; terminal-text framing is covered separately by deterministic regressions.
For a replay, pass a fresh screenshot filename in the same screenshots directory; the probe refuses to overwrite earlier evidence.
Do NOT use AskUserQuestion — run Quick tier directly.
Use ${qaOnlyDir}/qa-reports as the caller-owned directory for all reports, checkpoints and screenshots.
${deadlinePolicy.prompt}
Run guarded calls sequentially, waiting for each result before dispatching the next call.
Write your report to ${qaOnlyDir}/qa-reports/qa-only-report.md`,
workingDirectory: qaOnlyDir,
maxTurns: 40,
allowedTools: ['Bash', 'Read', 'Write', 'Glob'], // NO Edit — the critical guardrail
tools: ['Bash', 'Read', 'Write', 'Glob'],
timeout: CAPTURE_MS,
testName: 'qa-only-no-fix',
runId,
});
workingDirectory: qaOnlyDir,
maxTurns: 40,
allowedTools: ['Bash', 'Read', 'Write', 'Glob'],
tools: ['Bash', 'Read', 'Write', 'Glob'],
timeout: remainingWorkMs(),
testName: 'qa-only-no-fix',
runId: attemptRunId,
publicStreamDiagnostics: true,
});
logCost('/qa-only', result);
logCost('/qa-only', result);
const editCalls = result.toolCalls.filter(tc => tc.tool === 'Edit');
if (editCalls.length > 0) {
console.warn('qa-only used Edit tool:', editCalls.length, 'times');
}
expect(editCalls).toHaveLength(0);
expect(['success', 'error_max_turns']).toContain(result.exitReason);
// Verify Edit was not used — the critical guardrail for report-only mode.
// Glob is read-only and may be used for file discovery (e.g. finding SKILL.md).
const editCalls = result.toolCalls.filter(tc => tc.tool === 'Edit');
if (editCalls.length > 0) {
console.warn('qa-only used Edit tool:', editCalls.length, 'times');
const gitStatus = spawnSync('git', ['status', '--porcelain'], {
cwd: qaOnlyDir, stdio: 'pipe', timeout: 30_000,
});
if (gitStatus.error) throw gitStatus.error;
expect(gitStatus.status, gitStatus.stderr.toString()).toBe(0);
const statusLines = gitStatus.stdout.toString().trim().split('\n').filter(
(l: string) => l.trim() && !l.includes('.prompt-tmp') && !l.includes('.gstack/') && !l.includes('qa-reports/'),
);
expect(statusLines).toHaveLength(0);
assertQaBrowserDeadline(result.toolCalls, {
directory: qaOnlyDir, guard: path.join(ROOT, 'bin/gstack-qa-deadline'), browse: browseBin,
started, ended: Date.now(),
});
assertQaBrowserPreparation(result.transcript, {
directory: qaOnlyDir, guard: path.join(ROOT, 'bin/gstack-qa-deadline'),
});
assertQaBrowserCheckpoints(result.transcript, {
directory: qaOnlyDir, guard: path.join(ROOT, 'bin/gstack-qa-deadline'),
});
passed = true;
} catch (error) {
failure = error;
} finally {
let artifactFailure: unknown;
const fail = (error: unknown, stage: string) => {
failure = failure ? new AggregateError([failure, error], `QA-only attempt and ${stage} failed: ${failure instanceof Error ? failure.message : failure}; ${error instanceof Error ? error.message : error}`) : error;
passed = false;
};
try {
if (qaOnlyDir && fs.existsSync(path.join(qaOnlyDir, 'qa-reports'))) {
const artifactDir = path.join(path.dirname(getProjectEvalDir()), 'e2e-runs', attemptRunId);
fs.mkdirSync(artifactDir, { recursive: true });
fs.cpSync(path.join(qaOnlyDir, 'qa-reports'), path.join(artifactDir, 'qa-reports'), { recursive: true });
}
} catch (error) {
artifactFailure = error;
fail(error, 'artifact preservation');
}
let browserStopped = false;
try {
if (qaOnlyDir) await stopQaOnlyBrowser(qaOnlyDir, Math.min(4_000, started + CAPTURE_MS + finalizeMs - Date.now() - 1_000));
browserStopped = true;
} catch (error) {
fail(error, 'browser cleanup');
}
try {
testServer?.server.stop();
if (qaOnlyDir && browserStopped && !artifactFailure) fs.rmSync(qaOnlyDir, { recursive: true, force: true });
} catch (error) {
fail(error, 'fixture cleanup');
}
const error = failure instanceof Error ? failure.message : String(failure ?? 'QA-only attempt did not complete');
try {
if (result) {
const billed = typeof result.transcript.findLast(event => event.type === 'result')?.total_cost_usd === 'number';
recordE2E(evalCollector, '/qa-only no-fix', 'QA-Only skill E2E', result, {
passed, ...(passed ? {} : { error: billed ? error : `${error}\nNo terminal billing result; actual cost is unknown.` }),
});
} else {
evalCollector?.addTest({
name: '/qa-only no-fix', suite: 'QA-Only skill E2E', tier: 'e2e', passed: false,
duration_ms: Date.now() - started, cost_usd: 0,
model: process.env.EVALS_MODEL ?? resolveEvalModel('capture'),
exit_reason: 'harness_error',
error: `${error}\nRunner returned no result; cost and usage unavailable.`,
});
}
} catch (recordError) {
fail(recordError, 'recording');
}
if (failure) throw failure;
}
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, '/qa-only no-fix', 'QA-Only skill E2E', result, {
passed: exitOk && editCalls.length === 0,
});
expect(editCalls).toHaveLength(0);
// Accept error_max_turns — the agent doing thorough QA is not a failure
expect(['success', 'error_max_turns']).toContain(result.exitReason);
// Verify git working tree is still clean (no source modifications)
const gitStatus = spawnSync('git', ['status', '--porcelain'], {
cwd: qaOnlyDir, stdio: 'pipe', timeout: 30_000,
});
const statusLines = gitStatus.stdout.toString().trim().split('\n').filter(
(l: string) => l.trim() && !l.includes('.prompt-tmp') && !l.includes('.gstack/') && !l.includes('qa-reports/'),
);
expect(statusLines.filter((l: string) => l.startsWith(' M') || l.startsWith('M '))).toHaveLength(0);
}, CAPTURE_MS);
}, CAPTURE_MS + finalizeMs);
}, browserSelected);
// --- QA Fix Loop E2E ---
@@ -185,7 +255,7 @@ describeIfSelected('QA Fix Loop E2E', ['qa-fix-loop'], () => {
if (remaining <= 0) throw new Error('QA fix-loop work budget exhausted');
return remaining;
};
const qaFixDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-qa-fix-'));
const qaFixDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'skill-e2e-qa-fix-'));
let qaFixServer: ReturnType<typeof Bun.serve> | null = null;
let result: Awaited<ReturnType<typeof runSkillTest>> | undefined;
let passed = false;
@@ -399,6 +469,7 @@ export function divide(a, b) { return a / b; } // BUG: no zero check
});
testConcurrentIfSelected('qa-bootstrap', async () => {
const bootstrapDeadline = Date.now() + JUDGE_MS;
// Test ONLY the bootstrap phase — install vitest, create config, write one test
const bsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-bs-'));
@@ -421,8 +492,13 @@ export function divide(a, b) { return a / b; }
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
const result = await runSkillTest({
prompt: `This is a Node.js project with no test framework. It has a package.json and app.js with simple functions (add, subtract, divide).
const retention = process.env.GSTACK_BOOTSTRAP_RETENTION
? registerBootstrapRetention(bsDir, process.env.EVALS_RUN_ID || runId, { deadline: bootstrapDeadline })
: undefined;
let bootstrapFailure: unknown;
try {
const result = await runSkillTest({
prompt: `This is a Node.js project with no test framework. It has a package.json and app.js with simple functions (add, subtract, divide).
Set up a test framework:
1. Install vitest: bun add -d vitest
@@ -432,30 +508,42 @@ Set up a test framework:
5. Create TESTING.md explaining how to run tests
Do NOT fix any bugs. Do NOT use AskUserQuestion — just pick vitest.`,
workingDirectory: bsDir,
maxTurns: 12,
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob'],
timeout: JUDGE_MS,
testName: 'qa-bootstrap',
runId,
});
workingDirectory: bsDir,
maxTurns: 12,
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob'],
timeout: JUDGE_MS,
testName: 'qa-bootstrap',
runId,
...(retention ? { nativeLifecycle: retention.lifecycle } : {}),
});
logCost('/qa bootstrap', result);
logCost('/qa bootstrap', result);
const hasTestConfig = fs.existsSync(path.join(bsDir, 'vitest.config.ts'))
|| fs.existsSync(path.join(bsDir, 'vitest.config.js'));
const hasTestFile = fs.readdirSync(bsDir).some(f => f.includes('.test.'));
const hasTestingMd = fs.existsSync(path.join(bsDir, 'TESTING.md'));
const hasTestConfig = fs.existsSync(path.join(bsDir, 'vitest.config.ts'))
|| fs.existsSync(path.join(bsDir, 'vitest.config.js'));
const hasTestFile = fs.readdirSync(bsDir).some(f => f.includes('.test.'));
const hasTestingMd = fs.existsSync(path.join(bsDir, 'TESTING.md'));
recordE2E(evalCollector, '/qa bootstrap', 'Test Bootstrap E2E', result, {
passed: hasTestConfig && ['success', 'error_max_turns'].includes(result.exitReason),
});
recordE2E(evalCollector, '/qa bootstrap', 'Test Bootstrap E2E', result, {
passed: hasTestConfig && ['success', 'error_max_turns'].includes(result.exitReason),
});
expect(['success', 'error_max_turns']).toContain(result.exitReason);
expect(hasTestConfig).toBe(true);
console.log(`Test config: ${hasTestConfig}, Test file: ${hasTestFile}, TESTING.md: ${hasTestingMd}`);
try { fs.rmSync(bsDir, { recursive: true, force: true }); } catch {}
expect(['success', 'error_max_turns']).toContain(result.exitReason);
expect(hasTestConfig).toBe(true);
console.log(`Test config: ${hasTestConfig}, Test file: ${hasTestFile}, TESTING.md: ${hasTestingMd}`);
} catch (error) {
bootstrapFailure = error;
throw error;
} finally {
try {
if (retention) retention.cleanup();
else { try { fs.rmSync(bsDir, { recursive: true, force: true }); } catch {} }
}
catch (error) {
if (bootstrapFailure) throw new AggregateError([bootstrapFailure, error], 'bootstrap assertion and retention failed');
throw error;
}
}
}, JUDGE_MS);
});