* test(#3301): add failing-first plan coverage manifest tests Failing-first regression tests for the plan-id manifest, the updated Review Instructions, and the mechanical per-reviewer coverage check, ahead of the review.md implementation. RED baseline before the fix lands. * test(#3301): raise allow-test-rule-refs unverified ceiling for new marker Adding tests/review-plan-coverage-manifest.test.cjs's source-text-is-the-product marker grows the unverified-exemption pool by one (282 -> 283), the same documented growth path scripts/lint-allow-test-rule-refs.cjs's own failure output names. Confirmed clean via 'npm run lint:allow-test-rule-refs' locally. * feat(#3301): tell reviewers the plan ids and total count, grade coverage build_prompt now derives a plan-id manifest from each *-PLAN.md filename (stripping the -PLAN.md suffix) and appends it, with the total plan count, to both gsd-review-instructions.md and gsd-review-prompt.md. The Review Instructions prose requires one heading-verbatim section per id before any cross-plan or overall-risk content. write_reviews grades each dispatched lane's real (non-stub, non-empty) review against that same manifest and records an optional plan_coverage: frontmatter block, present only when a lane is incomplete. The match escapes regex metacharacters in the id and excludes a preceding/trailing hyphen or word character as a boundary, closing the two traps named in the issue (a decimal phase like 12.6 satisfied by 12X6-01; a threat id like T-04-07 registering as coverage of plan 04-07). CodeRabbit is exempt, since it never receives the source-grounding prompt carrying the manifest. This closes the gap where a review that silently covers only some plans in a multi-plan phase is indistinguishable from one that covers all of them. * docs(#3301): add changeset fragment * test(#3301): use t.after() instead of try/finally for cleanup CONTRIBUTING.md bans try/finally inside test bodies. Code review caught this in the new coverage-manifest test file; switch every fixture-cleanup site to the approved t.after() pattern. * test: use t.after() instead of try/finally in #3300's build_prompt tests Pre-existing try/finally-for-cleanup pattern in this file (landed for #3300) violates CONTRIBUTING.md's explicit ban on try/finally inside test bodies. Surfaced incidentally while reviewing #3301's diff, which cites this file as its extraction-pattern precedent; fixed inline per the no-defer rule rather than deferred to a separate PR. * test(#3301): anchor coverage-check extraction on the fence line, not prose `.plans-manifest.md` also appears in write_reviews' own prose ahead of the ```bash fence, so indexOf found that occurrence first and the backward-walk-to-fence-open landed on the earlier, unrelated gate-check block instead. gsd-test caught this: coverage-check tests expecting a real verdict got null, because the wrong block ran and never writes .plan-coverage-<slug>.json. Anchor on the fence-only bash assignment line instead. Emitted-Drift-Ack-Growth: review.md — #3301 adds the plan-coverage manifest and mechanical coverage check to build_prompt/write_reviews. * fix(#3301): route id escaping through the canonical pattern seam ADR-3212 (epic #3212) consolidated ~44 hand-rolled regex-escape copies into one owner, src/pattern.cts's escapeRegex, specifically to stop this exact class of duplication. My coverage-check node -e script hand-rolled the identical metachar-escape regex — invisible to eslint-rules/no-adhoc-regex-escape.cjs only because it lives inside a workflow markdown file, not a .cts/.cjs source file the shape-matching guard scans. Require the compiled seam (gsd-core/bin/lib/pattern.cjs) instead, matching the established node -e-requires-a-compiled-lib idiom already used elsewhere in this workflow (code-review.md's code-review-flags.cjs/code-review-depth.cjs calls). Verified both named traps from the issue still resolve correctly under escapeRegex's RegExp.escape-backed implementation, which differs in escaped-text shape (hex-escapes hyphens/leading chars) but not match result. * test(#3301): run coverage-check block with cwd at the repo root The block's node -e now requires ./gsd-core/bin/lib/pattern.cjs, a path relative to the repo root (correct for production, which always runs from there). The test harness ran it with cwd at the fixture's own temp dir instead, so the require failed. Add an optional cwd param to runScript (default: root, unchanged for the plan-copy-block tests) and pass the real repo root for every coverage-check call site. Manually verified end-to-end before spending another remote run: the extracted block now produces the expected {complete:true} verdict. * docs(#3301): backfill changeset pr number (pr:0 -> pr:4084) --------- Co-authored-by: sim <sim@local>
283 lines
13 KiB
JavaScript
283 lines
13 KiB
JavaScript
// 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 { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.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 lines = content.split(/\r?\n/);
|
|
const blocks = scanFencedBlocks(lines)
|
|
.filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash')
|
|
.map((b) => lines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n'));
|
|
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`, (t) => {
|
|
const fx = buildFixture({ '01-PLAN.md': 'plan\n' }); // the common case: PLAN only
|
|
t.after(() => cleanup(fx.root));
|
|
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');
|
|
});
|
|
|
|
test(`[${shell.name}] present optional sources: output matches the source exactly`, (t) => {
|
|
const fx = buildFixture({
|
|
'01-PLAN.md': 'plan\n',
|
|
'01-CONTEXT.md': 'ctx\n',
|
|
'01-RESEARCH.md': 'research\n',
|
|
});
|
|
t.after(() => cleanup(fx.root));
|
|
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');
|
|
});
|
|
|
|
test(`[${shell.name}] research-only phase: context output stays absent`, (t) => {
|
|
const fx = buildFixture({ '01-PLAN.md': 'plan\n', '01-RESEARCH.md': 'research\n' });
|
|
t.after(() => cleanup(fx.root));
|
|
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');
|
|
});
|
|
|
|
test(`[${shell.name}] multiple matches concatenate in glob order (original cat <glob> semantics)`, (t) => {
|
|
const fx = buildFixture({
|
|
'01-PLAN.md': 'plan\n',
|
|
'01-CONTEXT.md': 'A\n',
|
|
'02-CONTEXT.md': 'B\n',
|
|
});
|
|
t.after(() => cleanup(fx.root));
|
|
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');
|
|
});
|
|
|
|
test(`[${shell.name}] open, unconsumed stdin pipe: block completes within ${STDIN_BOUND_MS}ms`, async (t) => {
|
|
const fx = buildFixture({ '01-PLAN.md': 'plan\n' });
|
|
t.after(() => cleanup(fx.root));
|
|
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})` : ''}`);
|
|
});
|
|
}
|
|
|
|
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.',
|
|
);
|
|
});
|
|
});
|