fix(ship): Step 9.2.1 QA writes reports under a git-excluded run dir, not the product tree (GST-969)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 23:30:02 +00:00
1 parent 82db89ab3e
commit 3e44a5af3c
5 files changed
+109 -2

No files matched your search

+26 -1
View File
@@ -388,7 +388,28 @@ function materialize(root: string, source: string) {
next: `Include every reportLinks entry in the Markdown report, and report the overall status as ${verdict.status}${verdict.open.length ? ` (open: ${verdict.open.join('; ')})` : ''}; this verdict is final. Next, write the Markdown report beside evidence.json.` };
}
const QA_EVIDENCE_USAGE = 'capture ROOT ID [--public] --deadline FILE|--timeout-ms MS [--after PREVIOUS_CAPTURE --hypothesis TEXT] -- COMMAND ARGS (--after publishes checkpoint ID linking PREVIOUS_CAPTURE to this probe; required after the first complete capture unless a checkpoint was published) | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS (annotations: {evidence: [{capture, command, contract, expected, classification: pass|superseded|product-defect|fail|setup-blocked|blocked|inconclusive}], limits: [..]}; revision, runtime, cwd and learning are filled in)';
/** A fresh report root for /review and /ship QA under the repository's git-excluded .gstack/tmp, so probes never change the reviewed tree. */
function reportDir(): Record<string, any> {
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 5000 });
if (top.status !== 0 || !top.stdout.trim()) throw new QaEvidenceError('report-dir needs a git work tree');
const repoRoot = fs.realpathSync(top.stdout.trim());
const exclude = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8', timeout: 5000 });
if (exclude.status !== 0 || !exclude.stdout.trim()) throw new QaEvidenceError('report-dir cannot locate the git exclude file');
const excludeFile = path.resolve(exclude.stdout.trim());
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
const excluded = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
if (!excluded.split(/\r?\n/).includes('/.gstack/tmp/')) fs.appendFileSync(excludeFile, `${excluded && !excluded.endsWith('\n') ? '\n' : ''}/.gstack/tmp/\n`);
const parent = path.join(repoRoot, '.gstack', 'tmp', 'qa-reports');
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
for (let n = 1; ; n++) {
const dir = path.join(parent, `run-${stamp}${n > 1 ? `-${n}` : ''}`);
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error: any) { if (error?.code === 'EEXIST') continue; throw error; }
return { action: 'report-dir', status: 'complete', reportDir: dir, exitCode: 0 };
}
}
const QA_EVIDENCE_USAGE = 'report-dir (prints a fresh git-excluded report root for /review and /ship) | capture ROOT ID [--public] --deadline FILE|--timeout-ms MS [--after PREVIOUS_CAPTURE --hypothesis TEXT] -- COMMAND ARGS (--after publishes checkpoint ID linking PREVIOUS_CAPTURE to this probe; required after the first complete capture unless a checkpoint was published) | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS (annotations: {evidence: [{capture, command, contract, expected, classification: pass|superseded|product-defect|fail|setup-blocked|blocked|inconclusive}], limits: [..]}; revision, runtime, cwd and learning are filled in)';
export async function qaEvidenceMain(args: string[]): Promise<number> {
return withQaReceiptOutput(false, 'qa-evidence-receipt', value => value.event === 'observation'
@@ -400,6 +421,10 @@ export async function qaEvidenceMain(args: string[]): Promise<number> {
emit('stdout', { action: 'help', status: 'complete', usage: QA_EVIDENCE_USAGE, exitCode: 0 });
return 0;
}
if (action === 'report-dir' && args.length === 1) {
emit('stdout', reportDir());
return 0;
}
const root = qaEvidenceRoot(reportRoot);
let receipt: Record<string, any>;
const publicOutput = action === 'capture' && rest[1] === '--public';
+1 -1
View File
@@ -244,7 +244,7 @@ export function generateQAReview(ctx: TemplateContext): string {
Only the parent runs report-only discovery.
Never overwrite another run's reports. Batch only independent Reads.
${ship ? `Without a caller REPORT_DIR, use the \`reportDir\` that \`bun ${quoteSafePath(ctx.paths.binDir)}/gstack-qa-evidence report-dir\` prints: git-excluded, so QA never changes the reviewed tree.\n` : ''}
${ship ? `**1. Load methods before any QA or explicit-verification probe.**
${generateQAReviewPreflight(ctx)}` : `**1. Set the charter and isolation.**
+1
View File
@@ -594,6 +594,7 @@ If the Red Team fails or times out, confirm it stopped and record its review as
Only the parent runs report-only discovery.
Never overwrite another run's reports. Batch only independent Reads.
Without a caller REPORT_DIR, use the `reportDir` that `bun $HOME/.claude/skills/gstack/bin/gstack-qa-evidence report-dir` prints: git-excluded, so QA never changes the reviewed tree.
**1. Load methods before any QA or explicit-verification probe.**
+77
View File
@@ -0,0 +1,77 @@
import { afterAll, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { RESOLVERS } from '../scripts/resolvers';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
const BIN = path.resolve(import.meta.dir, '../bin');
const ROOT = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qa-report-dir-'));
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const IDENTITY = { GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com' };
function product() {
const root = fs.mkdtempSync(path.join(ROOT, 'product-'));
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 10_000, env: { ...process.env, ...IDENTITY } });
expect(r.status, r.stderr).toBe(0);
return r.stdout;
};
git('init', '-q');
fs.writeFileSync(path.join(root, 'app.ts'), 'export const x = 1;\n');
git('add', 'app.ts');
git('commit', '-q', '-m', 'seed');
const tool = (name: string, ...args: string[]) => {
const r = spawnSync(process.execPath, [path.join(BIN, name), ...args], { cwd: root, encoding: 'utf8', timeout: 15_000 });
expect(r.error, r.error?.message).toBeUndefined();
return r;
};
const tree = () => git('status', '--porcelain', '--untracked-files=all', '--ignored=no') + git('ls-files', '--others', '--exclude-standard');
return { root, tool, tree };
}
function reportDir(f: ReturnType<typeof product>): string {
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status, r.stderr).toBe(0);
return JSON.parse(r.stdout.trim().slice('QA_EVIDENCE '.length)).reportDir;
}
test('the /ship QA report root leaves a product tree without a .gstack ignore rule unchanged', () => {
const f = product();
expect(f.tree()).toBe('');
const dir = reportDir(f);
expect(path.relative(f.root, dir).split(path.sep).slice(0, 3)).toEqual(['.gstack', 'tmp', 'qa-reports']);
const deadline = path.join(dir, 'deadline.json');
expect(f.tool('gstack-qa-deadline', 'start', deadline, '300').status).toBe(0);
const capture = f.tool('gstack-qa-evidence', 'capture', dir, '001', '--public', '--deadline', deadline, '--', process.execPath, '-e', 'console.log(JSON.stringify({ ok: true }))');
expect(capture.status, capture.stderr).toBe(0);
fs.writeFileSync(path.join(dir, 'report.md'), '## Exploratory QA\n');
expect(f.tree()).toBe('');
});
test('each report-dir call owns a fresh run so a second QA run never overwrites the first', () => {
const f = product();
const first = reportDir(f);
const second = reportDir(f);
expect(second).not.toBe(first);
expect(fs.readdirSync(first)).toEqual([]);
expect(fs.readdirSync(second)).toEqual([]);
});
test('report-dir refuses outside a git work tree instead of guessing a location', () => {
const outside = fs.mkdtempSync(path.join(ROOT, 'plain-'));
const r = spawnSync(process.execPath, [path.join(BIN, 'gstack-qa-evidence'), 'report-dir'], { cwd: outside, encoding: 'utf8', timeout: 15_000, env: { ...process.env, GIT_CEILING_DIRECTORIES: ROOT } });
expect(r.status).toBe(2);
expect(r.stderr).toContain('report-dir needs a git work tree');
expect(fs.readdirSync(outside)).toEqual([]);
});
test('/ship Step 9.2.1 resolves REPORT_DIR through report-dir, not the product tree', () => {
const ctx: TemplateContext = { host: 'claude', skillName: 'ship', tmplPath: '', paths: HOST_PATHS.claude };
expect(RESOLVERS.QA_REVIEW(ctx)).toContain('/gstack/bin/gstack-qa-evidence report-dir` prints: git-excluded');
const rendered = fs.readFileSync(path.resolve(import.meta.dir, '../ship/sections/review-army.md'), 'utf8');
expect(rendered).toContain('gstack-qa-evidence report-dir` prints: git-excluded');
});
@@ -0,0 +1,4 @@
{
"bytes": 183,
"why": "GST-969: /ship Step 9.2.1 QA writes its reports under gstack-qa-evidence report-dir (git-excluded .gstack/tmp), not the product tree"
}