fix: harden report-dir (linked .gstack, .gitignore negation, error cause) per Step 11 voices

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:52:06 +00:00
1 parent 7672a4f07e
commit 5805f00417
5 files changed
+45 -8

No files matched your search

+15 -4
View File
@@ -400,16 +400,25 @@ function reportDir(): Record<string, any> {
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 });
for (const dir of [path.join(repoRoot, '.gstack'), path.dirname(parent), parent]) {
try {
if (fs.lstatSync(dir).isSymbolicLink()) throw new QaEvidenceError(`report-dir refuses the linked ${path.relative(repoRoot, dir)}`);
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error;
fs.mkdirSync(dir, { mode: 0o700 });
}
}
const ignored = spawnSync('git', ['check-ignore', '-q', path.join(parent, 'report.md')], { cwd: repoRoot, encoding: 'utf8', timeout: 5000 });
if (ignored.status !== 0) throw new QaEvidenceError('report-dir: .gstack/tmp is not git-ignored here (a .gitignore rule overrides info/exclude)');
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 };
return { action: 'report-dir', status: 'complete', reportDir: qaEvidenceRoot(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)';
const QA_EVIDENCE_USAGE = 'report-dir (prints a fresh git-excluded report root for /ship QA) | 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'
@@ -422,7 +431,9 @@ export async function qaEvidenceMain(args: string[]): Promise<number> {
return 0;
}
if (action === 'report-dir' && args.length === 1) {
emit('stdout', reportDir());
let created: Record<string, any>;
try { created = reportDir(); } catch (error: any) { throw error instanceof QaEvidenceError ? error : new QaEvidenceError(`report-dir failed: ${error?.code ?? 'unknown error'}`); }
emit('stdout', created);
return 0;
}
const root = qaEvidenceRoot(reportRoot);
+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 \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-evidence report-dir\` prints: git-excluded, so QA never changes the reviewed tree.\n` : ''}
${ship ? `Without a caller REPORT_DIR, set it to the \`reportDir\` that \`${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 -1
View File
@@ -594,7 +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 `$HOME/.claude/skills/gstack/bin/gstack-qa-evidence report-dir` prints: git-excluded, so QA never changes the reviewed tree.
Without a caller REPORT_DIR, set it to the `reportDir` that `$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.**
+27 -1
View File
@@ -61,6 +61,32 @@ test('each report-dir call owns a fresh run so a second QA run never overwrites
expect(fs.readdirSync(second)).toEqual([]);
});
test('report-dir refuses a linked .gstack instead of creating directories through it', () => {
const f = product();
const elsewhere = fs.mkdtempSync(path.join(ROOT, 'elsewhere-'));
fs.symlinkSync(elsewhere, path.join(f.root, '.gstack'));
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status).toBe(2);
expect(r.stderr).toContain('report-dir refuses the linked .gstack');
expect(fs.readdirSync(elsewhere)).toEqual([]);
});
test('report-dir refuses when a product .gitignore un-ignores .gstack/tmp', () => {
const f = product();
fs.writeFileSync(path.join(f.root, '.gitignore'), '!/.gstack/tmp/\n!/.gstack/tmp/**\n');
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status).toBe(2);
expect(r.stderr).toContain('.gstack/tmp is not git-ignored here');
});
test('report-dir names the filesystem cause when .gstack is not a directory', () => {
const f = product();
fs.writeFileSync(path.join(f.root, '.gstack'), 'not a directory\n');
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status).toBe(2);
expect(r.stderr).toContain('report-dir failed: ENOTDIR');
});
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 } });
@@ -71,7 +97,7 @@ test('report-dir refuses outside a git work tree instead of guessing a location'
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('that `$HOME/.claude/skills/gstack/bin/gstack-qa-evidence report-dir` prints: git-excluded');
expect(RESOLVERS.QA_REVIEW(ctx)).toContain('set it to the `reportDir` that `$HOME/.claude/skills/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');
});
@@ -1,4 +1,4 @@
{
"bytes": 179,
"bytes": 185,
"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"
}