* fix(#2358): scope review workflow temp files to a per-run mktemp dir /gsd-review wrote every prompt/section/output temp file to a hardcoded /tmp path keyed only on the phase number, so two GSD projects sharing a small phase number collide on the exact same path and a crashed run's leftover file becomes bait a later, unrelated run can silently read. ship.md's external peer-review stderr capture was strictly worse — one shared, unqualified path across every project/phase/run. Thread a single mktemp -d "${TMPDIR:-/tmp}/gsd-review.XXXXXX" run directory through every review.md temp path (67 sites) via a new {run_dir}/$RUN_DIR placeholder, mirroring the existing {phase} substitution mechanism, and clean it up at the end of the run. Route ship.md's stderr capture through a per-run mktemp file the same way. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#2358): regenerate fixtures + lint gate-prep * fix(#2358): repair failing tests after gate verification * fix(#2358): thread RUN_DIR scoping into reviewer-instances.md (#1517) review.md's own invoke_reviewers step lazily loads gsd-core/references/reviewer-instances.md for the review.reviewer_instances codepath, but that doc was missed when review.md and ship.md were moved to the run-scoped {run_dir} temp directory. It still read the combined prompt from the old /tmp/gsd-review-prompt-{phase}.md (which build_prompt no longer writes, breaking reviewer-instances functionality outright) and wrote each instance's output to the old unscoped /tmp/gsd-review-${INSTANCE_NAME}-{phase}.md, leaving the exact cross-project temp-file collision bug open for that code path. Both paths now thread through {run_dir}, matching every other reviewer block in review.md. Extends the existing #2358 regression test with assertions pinning reviewer-instances.md's prompt read and output write to {run_dir}, and adds the Fixed changeset fragment. Regenerated the golden-install-parity content hashes for reviewer-instances.md via `npm run gen:golden` (paths unchanged; only the modified file's hash moved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#2358): backfill changeset pr (#2433) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
202 lines
9.6 KiB
JavaScript
202 lines
9.6 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* #2358 — review.md (and ship.md's external peer-review step) wrote every
|
|
* temp file to a hardcoded, phase-number-only path under /tmp
|
|
* (`/tmp/gsd-review-prompt-{phase}.md`, `/tmp/gsd-review-<reviewer>-{phase}.*`,
|
|
* `/tmp/gsd-review-stderr.log`). Two GSD projects with a phase sharing the
|
|
* same small integer number collide on the exact same path; a crashed prior
|
|
* run's leftover file is bait a later, unrelated run can silently read (the
|
|
* reporter forensically confirmed agy read a 3-week-old stale prompt from a
|
|
* DIFFERENT project). Neither file used the portable ${TMPDIR:-/tmp} seam,
|
|
* and review.md had no cleanup.
|
|
*
|
|
* The fix threads a single `mktemp -d "${TMPDIR:-/tmp}/gsd-review-XXXXXX"`
|
|
* run directory (RUN_DIR / {run_dir}) through every review.md temp path, and
|
|
* ship.md's stderr capture through a per-run `mktemp` file — eliminating the
|
|
* shared-path collision by construction rather than by convention.
|
|
*
|
|
* review.md and ship.md ARE the product the runtime loads (an AI agent reads
|
|
* and executes these workflow instructions verbatim), so this is a
|
|
* static-content regression against the deployed text, mirroring
|
|
* fix-2194-review-timeout-guidance.test.cjs.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const REVIEW_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
|
|
const SHIP_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md');
|
|
const REVIEWER_INSTANCES_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'reviewer-instances.md');
|
|
|
|
describe('#2358 review.md temp paths are run-scoped, not phase-only', () => {
|
|
const content = fs.readFileSync(REVIEW_MD, 'utf-8');
|
|
|
|
test('no bare, unscoped /tmp/gsd-review-* path remains', () => {
|
|
assert.ok(
|
|
!content.includes('/tmp/gsd-review'),
|
|
'review.md must not contain any hardcoded /tmp/gsd-review* literal — ' +
|
|
'every review temp path must be rooted under the run-scoped mktemp directory'
|
|
);
|
|
});
|
|
|
|
test('creates exactly one run-scoped directory via the portable ${TMPDIR:-/tmp} seam', () => {
|
|
const mktempAssignments = content.match(/RUN_DIR=\$\(mktemp -d "\$\{TMPDIR:-\/tmp\}\/gsd-review-XXXXXX"\)/g) || [];
|
|
assert.equal(
|
|
mktempAssignments.length, 1,
|
|
'review.md must create the run directory with exactly one `mktemp -d "${TMPDIR:-/tmp}/gsd-review-XXXXXX"` — ' +
|
|
'a hardcoded /tmp (no ${TMPDIR:-/tmp} seam) breaks on Windows, and re-mktemp-ing per block would break the ' +
|
|
'write/read pairing between build_prompt and the local-reviewer budget-trimming reads'
|
|
);
|
|
});
|
|
|
|
test('every downstream temp path is threaded through {run_dir} / $RUN_DIR, not re-derived from {phase}', () => {
|
|
assert.ok(
|
|
/\{run_dir\}\/gsd-review-/.test(content),
|
|
'reviewer blocks must reference {run_dir}/gsd-review-... (the run-scoped placeholder)'
|
|
);
|
|
assert.ok(
|
|
/\$\{RUN_DIR\}\/gsd-review-/.test(content),
|
|
'the build_prompt section-file writes must reference ${RUN_DIR}/gsd-review-... (the run-scoped shell var)'
|
|
);
|
|
// The old isolation key must be gone entirely from path construction.
|
|
assert.ok(
|
|
!/\/tmp\/gsd-review[^\r\n]*\{phase\}/.test(content),
|
|
'no temp path may still be keyed on a bare {phase} placeholder'
|
|
);
|
|
assert.ok(
|
|
!/\$\{PHASE\}-(?:instructions|roadmap|plan|project|context|research|requirements)\.md/.test(content),
|
|
'no temp path may still be keyed on the ${PHASE} shell var'
|
|
);
|
|
});
|
|
|
|
test('the Antigravity reviewer prompt-instruction string references the run-scoped path', () => {
|
|
const agyPromptMatch = content.match(/_AGY_PROMPT="Read the file at ([^ ]+)/);
|
|
assert.ok(agyPromptMatch, '_AGY_PROMPT must contain a "Read the file at <path>" instruction');
|
|
assert.equal(
|
|
agyPromptMatch[1], '{run_dir}/gsd-review-prompt.md',
|
|
'the Antigravity reviewer must be told to read the run-scoped prompt path, not a bare {phase}-only /tmp path — ' +
|
|
'this is the exact instruction text the reporter forensically traced back to a stale cross-project read'
|
|
);
|
|
});
|
|
|
|
test('the Cursor reviewer prompt-instruction string references the run-scoped path', () => {
|
|
const cursorPromptMatch = content.match(/CURSOR_PROMPT_ARG="Read the file at ([^ ]+)/);
|
|
assert.ok(cursorPromptMatch, 'CURSOR_PROMPT_ARG must contain a "Read the file at <path>" instruction');
|
|
assert.equal(
|
|
cursorPromptMatch[1], '{run_dir}/gsd-review-prompt.md',
|
|
'the Cursor reviewer must be told to read the run-scoped prompt path, not a bare {phase}-only /tmp path'
|
|
);
|
|
});
|
|
|
|
test('the run directory is cleaned up at the end of the review', () => {
|
|
const presentResultsStart = content.indexOf('<step name="present_results">');
|
|
assert.notEqual(presentResultsStart, -1, 'review.md must contain the present_results step');
|
|
const section = content.slice(presentResultsStart, presentResultsStart + 1500);
|
|
assert.ok(
|
|
/rm -rf "\{run_dir\}"/.test(section),
|
|
'present_results must remove the run-scoped temp directory once REVIEWS.md is written'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#2358 ship.md external-review stderr capture is run-scoped', () => {
|
|
const content = fs.readFileSync(SHIP_MD, 'utf-8');
|
|
|
|
test('no bare, unqualified /tmp/gsd-review-stderr.log path remains', () => {
|
|
assert.ok(
|
|
!content.includes('/tmp/gsd-review-stderr.log'),
|
|
'ship.md must not write/read a shared, unqualified stderr log path — every ship run, phase, and project ' +
|
|
'shares this exact path with zero disambiguator, which is strictly worse than review.md\'s phase-only keying'
|
|
);
|
|
});
|
|
|
|
test('stderr is captured to a per-run file via the portable ${TMPDIR:-/tmp} seam', () => {
|
|
assert.ok(
|
|
/REVIEW_STDERR_FILE=\$\(mktemp "\$\{TMPDIR:-\/tmp\}\/gsd-review-stderr-XXXXXX"\)/.test(content),
|
|
'ship.md must create the stderr capture file via `mktemp "${TMPDIR:-/tmp}/gsd-review-stderr-XXXXXX"`'
|
|
);
|
|
assert.ok(
|
|
/2>"\$\{REVIEW_STDERR_FILE\}"/.test(content),
|
|
'the external review command invocation must redirect stderr to the per-run $REVIEW_STDERR_FILE, not a literal path'
|
|
);
|
|
assert.ok(
|
|
/cat "\$\{REVIEW_STDERR_FILE\}"/.test(content),
|
|
'the failure-handling block must read back the same per-run $REVIEW_STDERR_FILE'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#2358 reviewer-instances.md (#1517, lazily loaded from invoke_reviewers) is run-scoped too', () => {
|
|
// review.md's own invoke_reviewers step lazily loads this companion doc for the
|
|
// review.reviewer_instances codepath — it was missed in the initial pass and still
|
|
// pointed at the old, unscoped /tmp/gsd-review-*-{phase} paths, which both broke
|
|
// reviewer-instances functionality (the prompt file build_prompt now writes lives
|
|
// at {run_dir}/gsd-review-prompt.md, never the old path) and left the exact
|
|
// cross-project collision bug open for that code path.
|
|
const content = fs.readFileSync(REVIEWER_INSTANCES_MD, 'utf-8');
|
|
|
|
test('no bare, unscoped /tmp/gsd-review-* path remains', () => {
|
|
assert.ok(
|
|
!content.includes('/tmp/gsd-review'),
|
|
'reviewer-instances.md must not contain any hardcoded /tmp/gsd-review* literal — ' +
|
|
'every review temp path must be rooted under the run-scoped {run_dir} directory'
|
|
);
|
|
});
|
|
|
|
test('no temp path is still keyed on a bare {phase} placeholder', () => {
|
|
assert.ok(
|
|
!/gsd-review[^\r\n]*\{phase\}/.test(content),
|
|
'no temp path may still be keyed on a bare {phase} placeholder'
|
|
);
|
|
});
|
|
|
|
test('the instance prompt read and output write are threaded through {run_dir}', () => {
|
|
assert.ok(
|
|
/\{run_dir\}\/gsd-review-prompt\.md/.test(content),
|
|
'reviewer-instances.md must read the prompt from {run_dir}/gsd-review-prompt.md, ' +
|
|
'the same run-scoped path build_prompt writes in review.md'
|
|
);
|
|
assert.ok(
|
|
/\{run_dir\}\/gsd-review-\$\{INSTANCE_NAME\}\.md/.test(content),
|
|
'reviewer-instances.md must write each instance\'s output to ' +
|
|
'{run_dir}/gsd-review-${INSTANCE_NAME}.md, not a {phase}-keyed /tmp path'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#2358 design principle: run-scoped temp dirs never collide across projects/phases', () => {
|
|
// review.md and ship.md are markdown instructions an AI agent executes, not
|
|
// node-executable code, so this does not shell out to the literal snippet —
|
|
// it validates the underlying guarantee the fix relies on (mktemp-style
|
|
// randomized-suffix isolation) using Node's built-in equivalent, which is
|
|
// cross-platform (Windows included) unlike shelling out to `mktemp`/bash.
|
|
test('two runs — even for the same phase number, same or different project — get distinct run dirs', () => {
|
|
const prefix = path.join(os.tmpdir(), 'gsd-review-');
|
|
const runDirA = fs.mkdtempSync(prefix);
|
|
const runDirB = fs.mkdtempSync(prefix);
|
|
try {
|
|
assert.notEqual(
|
|
runDirA, runDirB,
|
|
'two review runs sharing the same phase number must never resolve to the same run-scoped directory'
|
|
);
|
|
const phase = '10'; // same phase number in both "projects" — the historical collision case
|
|
const staleProjectAPath = path.join(runDirA, `gsd-review-prompt.md`);
|
|
const laterProjectBPath = path.join(runDirB, `gsd-review-prompt.md`);
|
|
assert.notEqual(
|
|
staleProjectAPath, laterProjectBPath,
|
|
`phase ${phase} in two different runs must not resolve to the same prompt path`
|
|
);
|
|
} finally {
|
|
// helpers.cleanup (not raw fs.rmSync) carries the Windows-EBUSY retry budget.
|
|
cleanup(runDirA);
|
|
cleanup(runDirB);
|
|
}
|
|
});
|
|
});
|