fix(#3300): guard build_prompt optional sections against nullglob (#3454)

* fix(#3300): guard build_prompt optional sections against nullglob

* chore(#3300): backfill changeset pr number 3454

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 02:53:19 -04:00
committed by GitHub
parent 7d08d80234
commit 011153c076
4 changed files with 308 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3454
---
gsd-review no longer creates empty gsd-review-context.md / gsd-review-research.md section files (or hangs waiting on input) when a phase has no CONTEXT/RESEARCH notes: the build_prompt guards now test the glob expansion itself instead of probing with ls, which the block's nullglob setting had made always-true.

View File

@@ -266,11 +266,13 @@ done
if [ -f ".planning/PROJECT.md" ]; then
cp .planning/PROJECT.md "${RUN_DIR}/gsd-review-project.md"
fi
if ls "${PHASE_DIR}/"*"-CONTEXT.md" >/dev/null 2>&1; then
cat "${PHASE_DIR}/"*"-CONTEXT.md" > "${RUN_DIR}/gsd-review-context.md"
_CTX=( "${PHASE_DIR}"/*-CONTEXT.md )
if [ ${#_CTX[@]} -gt 0 ]; then
cat "${_CTX[@]}" > "${RUN_DIR}/gsd-review-context.md"
fi
if ls "${PHASE_DIR}/"*"-RESEARCH.md" >/dev/null 2>&1; then
cat "${PHASE_DIR}/"*"-RESEARCH.md" > "${RUN_DIR}/gsd-review-research.md"
_RESEARCH=( "${PHASE_DIR}"/*-RESEARCH.md )
if [ ${#_RESEARCH[@]} -gt 0 ]; then
cat "${_RESEARCH[@]}" > "${RUN_DIR}/gsd-review-research.md"
fi
if [ -f ".planning/REQUIREMENTS.md" ]; then
cp .planning/REQUIREMENTS.md "${RUN_DIR}/gsd-review-requirements.md"

View File

@@ -1,4 +1,4 @@
{
"maxFiles": 300,
"maxFiles": 301,
"grace": 3
}

View File

@@ -0,0 +1,296 @@
// allow-test-rule: source-text-is-the-product (see #3300)
// The workflow markdown IS the installed orchestration contract; these rows
// extract the real shipped bash and execute it, never a hand-copied duplicate.
'use strict';
/**
* #3300 — the `if ls <glob>` guards for the optional CONTEXT/RESEARCH sections
* of review.md's build_prompt block are defeated by the block's own nullglob
* shim (#2962): an unmatched glob expands to nothing, so `ls` runs with zero
* operands (lists the working directory, exits 0 — guard unconditionally true)
* and `cat` runs with zero operands and reads STDIN: a 0-byte section file at
* EOF, an indefinite hang on an open pipe.
*
* Behavioral seam: extract the REAL fenced build_prompt block from
* gsd-core/workflows/review.md (+ its steps/ fragments, via readWorkflowCombined),
* fill the `{run_dir}` placeholder exactly as the workflow host does, and run the
* whole block in a temp PHASE_DIR/RUN_DIR under bash and — when present — zsh,
* the two dialects the block's own `shopt -s nullglob; setopt NULL_GLOB` shim
* targets. Rows:
* 1-2 absent sources -> NO output file created at all (not even 0-byte)
* 3-6 present sources -> exact bytes, all matches concatenated in glob order
* 7-8 open, never-closed stdin pipe -> block completes within a short bound
* 9 structural: no `if ls` guard survives in any review.md bash fence, and
* the #2962 nullglob shim itself is still present (out-of-scope guard).
*
* Script transport is temp-FILE based, never `bash -c <script>`: #2650 proved a
* quote-dense multi-line script does not survive Windows argv serialization
* (CreateProcess flattening + Git Bash MSYS re-splitting mangles it in transit).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawn, spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const {
cleanup,
createTempDir,
readWorkflowCombined,
} = require('./helpers.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const REVIEW_WORKFLOW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
/** How long the open-stdin rows may run before the kill timer fires (issue
* criterion 3: "completion within a short bound"). The fixed block finishes in
* single-digit ms; 5s is two orders of magnitude of slack, and the timer turns
* the pre-fix hang into a clean assertion failure instead of a suite hang. */
const STDIN_BOUND_MS = 5000;
// bash is unconditional (GitHub's windows-latest lanes ship Git Bash; every
// existing bash-spawning test in this suite relies on that). zsh is probed —
// it is the second dialect the shim targets, but it is absent on windows
// lanes, so its rows skip rather than fail there.
function detectShells() {
const shells = [{ name: 'bash', cmd: 'bash' }];
const probe = spawnSync('zsh', ['-c', 'exit 0'], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
if (!probe.error && probe.status === 0) shells.push({ name: 'zsh', cmd: 'zsh' });
return shells;
}
const SHELLS = detectShells();
/**
* Extract the fenced ```bash block of build_prompt — the one that writes
* `${RUN_DIR}/gsd-review-context.md` — from review.md (+steps). Walks backward
* from the first `gsd-review-context.md` occurrence to its enclosing fence, the
* same pattern as tests/plan-phase-stall-detection.test.cjs. Throws (failing the
* whole describe) if the anchor or fence disappears, so a future relocation of
* the block cannot silently blank these rows.
*/
function extractBuildPromptBlock() {
const content = readWorkflowCombined(REVIEW_WORKFLOW);
const anchorIdx = content.indexOf('gsd-review-context.md');
assert.notEqual(anchorIdx, -1, 'gsd-review-context.md no longer appears in review.md (+steps)');
const before = content.slice(0, anchorIdx);
const fenceOpenRe = /```bash\r?\n/g;
let lastOpen = -1;
let m;
while ((m = fenceOpenRe.exec(before)) !== null) lastOpen = m.index + m[0].length;
assert.notEqual(lastOpen, -1, 'gsd-review-context.md is not inside a ```bash fence of review.md (+steps)');
const after = content.slice(lastOpen);
const closeIdx = after.indexOf('\n```');
assert.notEqual(closeIdx, -1, 'unterminated ```bash fence around gsd-review-context.md');
const body = after.slice(0, closeIdx);
// Bind the extraction to build_prompt, not just "some block that mentions the
// file": the instructions copy is unique to this block.
assert.ok(
body.includes('gsd-review-instructions.md'),
'extracted block writes gsd-review-context.md but not gsd-review-instructions.md — wrong block',
);
return body;
}
/** Every fenced ```bash block of review.md (+steps), for the structural row. */
function extractAllBashBlocks() {
const content = readWorkflowCombined(REVIEW_WORKFLOW);
const blocks = [];
const re = /```bash\r?\n([\s\S]*?)\r?\n```/g;
let m;
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
assert.ok(blocks.length > 0, 'no ```bash blocks found in review.md (+steps)');
return blocks;
}
/**
* Temp fixture shaped like the block's real inputs: PHASE_DIR with the given
* phase files, a fresh RUN_DIR, and the two always-copied section sources the
* block expects from prompt assembly. cwd is a dir with no .planning/, so the
* literal-path `[ -f .planning/... ]` guards take their absent branch.
*/
function buildFixture(phaseFiles) {
const root = createTempDir('gsd-3300-');
const phaseDir = path.join(root, 'phase');
const runDir = path.join(root, 'run');
fs.mkdirSync(phaseDir);
fs.mkdirSync(runDir);
for (const [name, body] of Object.entries(phaseFiles)) {
fs.writeFileSync(path.join(phaseDir, name), body);
}
fs.writeFileSync(path.join(root, 'instr.md'), 'instructions\n');
fs.writeFileSync(path.join(root, 'roadmap.md'), 'roadmap\n');
return {
root,
phaseDir,
runDir,
env: {
...process.env,
PHASE_DIR: phaseDir,
INSTRUCTIONS_BLOCK_FILE: path.join(root, 'instr.md'),
ROADMAP_SECTION_FILE: path.join(root, 'roadmap.md'),
},
};
}
/** Fill the workflow's `{run_dir}` placeholder and stage the script in a file. */
function stageScript(shell, body, fx) {
const scriptPath = path.join(fx.root, `block-${shell.name}.sh`);
fs.writeFileSync(scriptPath, body.split('{run_dir}').join(fx.runDir));
return scriptPath;
}
/** Run the block with stdin at EOF (`ignore`), bounded by PROBE_TIMEOUT_MS. */
function runBlock(shell, body, fx) {
const scriptPath = stageScript(shell, body, fx);
return spawnSync(shell.cmd, [scriptPath], {
cwd: fx.root,
env: fx.env,
encoding: 'utf8',
stdin: 'ignore',
timeout: PROBE_TIMEOUT_MS,
windowsHide: true,
});
}
/**
* Run the block with an OPEN, unconsumed stdin pipe and a kill timer. Never
* resolves later than STDIN_BOUND_MS. The pre-fix operand-less `cat` blocks on
* that pipe; the fixed block never reads stdin at all, so it exits on its own.
*/
function runBlockWithOpenStdin(shell, body, fx) {
const scriptPath = stageScript(shell, body, fx);
return new Promise((resolve) => {
const child = spawn(shell.cmd, [scriptPath], {
cwd: fx.root,
env: fx.env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
// Close our end so an orphaned `cat` (SIGKILL kills the shell, not its
// children) sees EOF and exits instead of outliving the test.
child.stdin.destroy();
resolve(result);
};
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish({ timedOut: true, code: null, signal: 'SIGKILL' });
}, STDIN_BOUND_MS);
child.on('error', (err) => finish({ timedOut: false, error: err }));
child.on('exit', (code, signal) => finish({ timedOut: false, code, signal }));
// stdin is deliberately never written to and never ended.
});
}
const readIfPresent = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null);
describe('#3300 build_prompt optional-section guards under nullglob', () => {
test('extracts the build_prompt block and keeps the #2962 nullglob shim (out-of-scope guard)', () => {
const body = extractBuildPromptBlock();
assert.ok(body.includes('nullglob'), 'block must keep the shopt nullglob half of the #2962 shim');
assert.ok(body.includes('NULL_GLOB'), 'block must keep the setopt NULL_GLOB half of the #2962 shim');
});
for (const shell of SHELLS) {
test(`[${shell.name}] absent optional sources: no context/research output file is created at all`, () => {
const fx = buildFixture({ '01-PLAN.md': 'plan\n' }); // the common case: PLAN only
try {
const res = runBlock(shell, extractBuildPromptBlock(), fx);
assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`);
// Row 1/2 of the matrix — the failing-first core. Pre-fix these exist
// as 0-byte files (the `>` redirect creates them before cat blocks or
// EOFs); post-fix they must not exist at all.
assert.strictEqual(
readIfPresent(path.join(fx.runDir, 'gsd-review-context.md')),
null,
'gsd-review-context.md must NOT be created when no *-CONTEXT.md exists',
);
assert.strictEqual(
readIfPresent(path.join(fx.runDir, 'gsd-review-research.md')),
null,
'gsd-review-research.md must NOT be created when no *-RESEARCH.md exists',
);
// The always-on parts of the block still did their job.
assert.strictEqual(readIfPresent(path.join(fx.runDir, 'gsd-review-plan-00.md')), 'plan\n');
} finally {
cleanup(fx.root);
}
});
test(`[${shell.name}] present optional sources: output matches the source exactly`, () => {
const fx = buildFixture({
'01-PLAN.md': 'plan\n',
'01-CONTEXT.md': 'ctx\n',
'01-RESEARCH.md': 'research\n',
});
try {
const res = runBlock(shell, extractBuildPromptBlock(), fx);
assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`);
assert.strictEqual(readIfPresent(path.join(fx.runDir, 'gsd-review-context.md')), 'ctx\n');
assert.strictEqual(readIfPresent(path.join(fx.runDir, 'gsd-review-research.md')), 'research\n');
} finally {
cleanup(fx.root);
}
});
test(`[${shell.name}] research-only phase: context output stays absent`, () => {
const fx = buildFixture({ '01-PLAN.md': 'plan\n', '01-RESEARCH.md': 'research\n' });
try {
const res = runBlock(shell, extractBuildPromptBlock(), fx);
assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`);
assert.strictEqual(readIfPresent(path.join(fx.runDir, 'gsd-review-context.md')), null);
assert.strictEqual(readIfPresent(path.join(fx.runDir, 'gsd-review-research.md')), 'research\n');
} finally {
cleanup(fx.root);
}
});
test(`[${shell.name}] multiple matches concatenate in glob order (original cat <glob> semantics)`, () => {
const fx = buildFixture({
'01-PLAN.md': 'plan\n',
'01-CONTEXT.md': 'A\n',
'02-CONTEXT.md': 'B\n',
});
try {
const res = runBlock(shell, extractBuildPromptBlock(), fx);
assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`);
assert.strictEqual(readIfPresent(path.join(fx.runDir, 'gsd-review-context.md')), 'A\nB\n');
} finally {
cleanup(fx.root);
}
});
test(`[${shell.name}] open, unconsumed stdin pipe: block completes within ${STDIN_BOUND_MS}ms`, async () => {
const fx = buildFixture({ '01-PLAN.md': 'plan\n' });
try {
const res = await runBlockWithOpenStdin(shell, extractBuildPromptBlock(), fx);
assert.ok(!res.timedOut, `block blocked on stdin (killed at ${STDIN_BOUND_MS}ms) — cat ran operand-less`);
assert.strictEqual(res.code, 0, `block exited ${res.code}${res.signal ? ` (signal ${res.signal})` : ''}`);
} finally {
cleanup(fx.root);
}
});
}
test('structural: no `if ls` guard remains in any review.md bash fence (the idiom, not the instance)', () => {
const offenders = [];
for (const [i, block] of extractAllBashBlocks().entries()) {
block.split('\n').forEach((line, j) => {
if (/^\s*if\s+ls\s/.test(line)) offenders.push(`block ${i + 1}, line ${j + 1}: ${line.trim()}`);
});
}
assert.deepEqual(
offenders,
[],
'review.md still guards a glob with `if ls` — under the block-level nullglob shim '
+ '(#2962) an unmatched glob leaves ls operand-less: it lists the cwd and exits 0, '
+ 'so the guard is always true and the following cat runs operand-less and reads '
+ 'stdin (#3300). Guard on the glob expansion itself instead.',
);
});
});