'use strict'; /** * Tests for verification-status module (issue #651). * * Covers: * 1. status: passed → routing * 2. status: gaps_found with phase token extraction * 3. status: human_needed → routing * 4. No *-VERIFICATION.md → 'missing' * 5. Frontmatter status present but unknown value → 'unknown' * 6. BROAD-GREP REGRESSION: body `status:` lines ignored, frontmatter wins * 7. PARITY: VERIFIER_STATUSES covered by routing table; gsd-verifier.md emitted statuses covered * 8. CRLF line endings in frontmatter * 9. Body-only file (no frontmatter block) → missing * 10. Nonexistent phase directory → missing * 11. Multiple *-VERIFICATION.md files, none matching the phase's own token → * alphabetically-first FALLBACK wins (the phase-pinned rule's #2 tier — * see #3492 below for the primary, phase-pinned tier) * 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation) * 13. #3357/#3492: `-VERIFICATION.md` resolution — resolveVerificationFile * unit coverage plus behavioral tests through readVerificationStatus and * findStaleVerificationSummary. THE CONTRACT (#3492): a candidate whose * name exactly matches THIS phase's own token always wins, even over a * different phase's canonically-shaped file; alphabetical-first among all * dashed candidates is only the fallback when no exact match exists. The * resolveVerificationFile unit tests are the reliable anchors for this — * the readVerificationStatus/findStaleVerificationSummary behavioral tests * are illustrative (their outcome also depends on directory-basename * token derivation, not exercised in isolation there). * * PORTABILITY: pure JS — no shell-outs, no bash fences. * Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE. */ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { cleanup } = require('./helpers.cjs'); const { runGit: seamRunGit, OUTCOME } = require('./helpers/process-seam.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { VERIFIER_STATUSES, VERIFICATION_ROUTING_TABLE, defaultPhaseCleanCommitTimesMs, resolveVerificationFile, resolveUatFile, readVerificationStatus, findStaleVerificationSummary, } = require('../gsd-core/bin/lib/verification.cjs'); // #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Create a temporary phase directory named like a real one (`NN-slug`), * inside a throwaway parent. #3511: a phase directory's own name determines * which files count as ITS artifacts, so a fixture whose basename does not * name the same phase as the files written into it is not a valid phase dir. * @param {string} suffix - test-distinguishing suffix for the parent * @param {string} phaseDirName - basename of the phase dir (default '01-foo') */ function mkPhaseDir(suffix, phaseDirName = '01-foo') { const parent = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`)); const phaseDir = path.join(parent, phaseDirName); fs.mkdirSync(phaseDir); return phaseDir; } /** * Write a *-VERIFICATION.md file with the given frontmatter status and * optional body content. * * @param {string} dir - Phase directory path * @param {string} filename - e.g. '01-review-VERIFICATION.md' * @param {string} status - Frontmatter status value * @param {string} [body] - Content after the closing `---` */ function writeVerificationMd(dir, filename, status, body = '') { const frontmatter = `---\nstatus: ${status}\n---\n`; fs.writeFileSync(path.join(dir, filename), frontmatter + body); } function setMtime(filePath, iso) { const time = new Date(iso); fs.utimesSync(filePath, time, time); } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('verification-status', () => { // ── Case 1: passed ──────────────────────────────────────────────────────── test('status: passed → next_command is empty, status is passed', () => { const dir = mkPhaseDir('passed'); try { writeVerificationMd(dir, '01-foo-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir); assert.equal(result.status, 'passed', 'status must be passed'); assert.equal(result.next_command, '', 'next_command must be empty for passed'); assert.ok(result.next_action.length > 0, 'next_action must be non-empty'); } finally { cleanup(path.dirname(dir)); } }); // ── Case 2: gaps_found with phase token extraction ──────────────────────── test('status: gaps_found in "03-foo" dir → next_command includes phase token 03', () => { // Phase dir basename starts with "03" — extractPhaseToken('03-foo') → '03' const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); const phaseDir = path.join(baseDir, '03-foo'); fs.mkdirSync(phaseDir); try { writeVerificationMd(phaseDir, '03-foo-VERIFICATION.md', 'gaps_found'); const result = readVerificationStatus(phaseDir); assert.equal(result.status, 'gaps_found', 'status must be gaps_found'); assert.ok( result.next_command.includes('03'), `next_command should include phase token '03'; got: ${result.next_command}`, ); assert.ok( result.next_command.includes('--gaps'), `next_command should include --gaps; got: ${result.next_command}`, ); assert.equal(result.next_command, '/gsd-plan-phase 03 --gaps'); } finally { cleanup(baseDir); } }); // ── Case 3: human_needed ────────────────────────────────────────────────── test('status: human_needed → status human_needed, next_command is empty', () => { // Deliberately non-numeric dir basename ("human-needed" has no digits at // all) — extractPhaseToken has no derivable token, so (a) isPhaseArtifact's // fail-safe still includes 01-hn-VERIFICATION.md as this "phase"'s own // report, and (b) the next_command number-append check (which requires a // PURELY numeric token) never fires. This is what this test is actually // pinning — see the comment below — so the dir name must stay non-numeric, // not the realistic 'NN-slug' default. const dir = mkPhaseDir('human-needed', 'human-needed'); try { writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed'); const result = readVerificationStatus(dir); assert.equal(result.status, 'human_needed'); // #2617: human_needed now names the command the next_action describes. // This fixture's dir is not phase-shaped, so no number is appended. assert.equal(result.next_command, '/gsd-verify-work'); assert.ok(result.next_action.length > 0); } finally { cleanup(path.dirname(dir)); } }); // ── Case 4: no *-VERIFICATION.md → missing ──────────────────────────────── test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => { // Non-numeric dir basename: next_command asserts no phase-number argument // is appended, which requires extractPhaseToken(dirName) to not be purely // numeric — see the human_needed test above for the same rationale. const dir = mkPhaseDir('missing', 'missing'); try { // write a non-matching file to confirm it is ignored fs.writeFileSync(path.join(dir, 'README.md'), '# phase'); const result = readVerificationStatus(dir); assert.equal(result.status, 'missing'); assert.equal(result.next_command, '/gsd-execute-phase'); assert.ok(result.next_action.includes('verify step never completed')); assert.ok( result.next_action.includes('does not re-run plans that already have a SUMMARY.md'), `next_action must reassure the user execute-phase will not redo work (#1762); got: ${result.next_action}`, ); } finally { cleanup(path.dirname(dir)); } }); // ── Case 5: unknown frontmatter status value ────────────────────────────── test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => { // Non-numeric dir basename: next_command asserts no phase-number argument // is appended — see the human_needed test above for the same rationale. const dir = mkPhaseDir('unknown', 'unknown'); try { writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus'); const result = readVerificationStatus(dir); assert.equal(result.status, 'unknown'); assert.equal(result.next_command, '/gsd-execute-phase'); assert.ok( result.next_action.includes('bogus'), `next_action should mention the raw value; got: ${result.next_action}`, ); assert.ok( result.next_action.includes('intentional non-standard marker'), `next_action must acknowledge an unrecognized status may be an intentional marker (#1762); got: ${result.next_action}`, ); } finally { cleanup(path.dirname(dir)); } }); // ── Case 6: BROAD-GREP REGRESSION (critical) ────────────────────────────── // // Frontmatter: `status: passed` // Body: a fenced code block containing `status: gaps_found` AND `status: human_needed` // Result MUST be 'passed' — proving body lines are NOT matched. // This is the exact failure mode that issue #586 / PR #650 hit. // test('BROAD-GREP REGRESSION: body status lines ignored, frontmatter status wins', () => { const dir = mkPhaseDir('broad-grep'); try { const bodyWithEmbeddedStatuses = [ '', '## Section', '', 'Some prose about the results.', '', '```yaml', 'status: gaps_found', 'gaps:', ' - fix the thing', '```', '', 'Another block:', '', '```', 'status: human_needed', '```', '', 'End of document.', ].join('\n'); writeVerificationMd(dir, '01-bg-VERIFICATION.md', 'passed', bodyWithEmbeddedStatuses); const result = readVerificationStatus(dir); assert.equal( result.status, 'passed', `Expected status 'passed' (frontmatter wins); got '${result.status}'. ` + 'Body status: lines must NOT be matched.', ); assert.equal(result.next_command, '', 'next_command must be empty for passed'); } finally { cleanup(path.dirname(dir)); } }); // ── Case 7: PARITY ASSERTION ────────────────────────────────────────────── // // (a) Every value in VERIFIER_STATUSES has a corresponding key in VERIFICATION_ROUTING_TABLE. // (b) Parse agents/gsd-verifier.md for emitted statuses via /→ \*\*status:\s*([a-z_]+)\*\*/g, // collect the set, and assert every emitted status is a routing key. // test('PARITY: VERIFIER_STATUSES covered by routing table', () => { for (const s of VERIFIER_STATUSES) { assert.ok( s in VERIFICATION_ROUTING_TABLE, `VERIFIER_STATUS '${s}' has no entry in VERIFICATION_ROUTING_TABLE`, ); } }); test('PARITY: gsd-verifier.md emitted statuses all have routing table entries', () => { const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md'); const content = fs.readFileSync(verifierPath, 'utf-8'); const emittedStatuses = new Set(); // Source (a): decision-tree arrow lines — `→ **status: **` // These are the per-branch emission points in Step 9 (the decision tree). const reArrow = /→ \*\*status:\s*([a-z_]+)\*\*/g; let m; while ((m = reArrow.exec(content)) !== null) { emittedStatuses.add(m[1]); } // Source (b): output-template line — `status: A | B | C` (pipe-delimited list // of permitted values inside the frontmatter template block in the section). // Anchored to lines that start with `status:` and contain `|` to avoid false // matches on prose sentences that happen to mention "status:". const reTemplate = /^status:\s+([a-z_]+(?:\s*\|\s*[a-z_]+)+)\s*$/gm; while ((m = reTemplate.exec(content)) !== null) { for (const token of m[1].split('|')) { const t = token.trim(); if (t) emittedStatuses.add(t); } } assert.ok( emittedStatuses.size > 0, 'No emitted statuses found in gsd-verifier.md — regex or file path may be wrong. ' + 'Checked: (a) → **status: X** arrow lines, (b) status: A | B | C template lines.', ); for (const s of emittedStatuses) { assert.ok( s in VERIFICATION_ROUTING_TABLE, `gsd-verifier.md emits status '${s}' but VERIFICATION_ROUTING_TABLE has no entry for it. ` + 'Add a route or remove/rename the status in gsd-verifier.md.', ); } }); // ── Edge cases ──────────────────────────────────────────────────────────── // CRLF line endings in frontmatter test('CRLF line endings in frontmatter → correct status parsed', () => { const dir = mkPhaseDir('crlf'); try { // Construct a file with CRLF line endings throughout const content = '---\r\nstatus: passed\r\nphase: 01-demo\r\n---\r\n\r\n# Body\r\n'; fs.writeFileSync(path.join(dir, '01-crlf-VERIFICATION.md'), content); const result = readVerificationStatus(dir); assert.equal(result.status, 'passed', 'CRLF frontmatter must parse to passed'); assert.equal(result.next_command, ''); } finally { cleanup(path.dirname(dir)); } }); // File with NO frontmatter block — body-only `status:` line must NOT be matched test('body-only file with no frontmatter block (status: in body) → missing', () => { const dir = mkPhaseDir('no-fm'); try { // No opening `---` — this is a plain markdown file with a status: line in the body const content = '# Phase Verification\n\nstatus: passed\n\nSome notes.\n'; fs.writeFileSync(path.join(dir, '01-nofm-VERIFICATION.md'), content); const result = readVerificationStatus(dir); assert.equal( result.status, 'missing', "A body-only status: line must NOT be read — result should be 'missing'", ); } finally { cleanup(path.dirname(dir)); } }); // Missing / nonexistent phase directory → missing test('nonexistent phase directory → missing', () => { const nonexistent = path.join(os.tmpdir(), 'gsd-651-nonexistent-' + Date.now()); const result = readVerificationStatus(nonexistent); assert.equal(result.status, 'missing', 'unreadable/nonexistent dir must return missing'); assert.equal(result.next_command, '/gsd-execute-phase'); }); // Multiple *-VERIFICATION.md files, NEITHER matching the phase dir's own // token → deterministic FALLBACK pick (first by sort). This is the #2 tier // of the #3492 phase-pinned rule, not the contract itself — see the // `#3357/#3492` describe block below for the primary, phase-pinned tier // (resolveVerificationFile unit tests are the reliable anchors there). // The dir basename ('multi', no digits) has no derivable phase token, so // scopeToPhase's isPhaseArtifact fail-safe passes BOTH candidates through // unfiltered (#3511: scopeToPhase is a plain filter with no other // fallback — a derivable token that matched neither file would empty the // set and this test would read 'missing', not exercise the alphabetical // tiebreak at all). test('multiple *-VERIFICATION.md files, none matching the phase token → alphabetically-first FALLBACK wins', () => { const dir = mkPhaseDir('multi', 'multi'); try { // Write two files: alphabetically "01-a" comes before "02-b" // "01-a" has passed; "02-b" has gaps_found — first by sort must win const fm = (status) => `---\nstatus: ${status}\n---\n`; fs.writeFileSync(path.join(dir, '01-a-VERIFICATION.md'), fm('passed')); fs.writeFileSync(path.join(dir, '02-b-VERIFICATION.md'), fm('gaps_found')); const result = readVerificationStatus(dir); assert.equal( result.status, 'passed', 'With no exact phase-token match, the first by lexicographic sort must be used', ); } finally { cleanup(path.dirname(dir)); } }); test('passed verification older than a summary returns stale', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); const dir = path.join(baseDir, '01-stale-passed'); fs.mkdirSync(dir); try { const verificationPath = path.join(dir, '01-VERIFICATION.md'); const summaryPath = path.join(dir, '01-01-SUMMARY.md'); writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); // git times unavailable → mtime-fallback path (#2348). Injected so the // test stays hermetic (no git spawn) regardless of tmpdir repo state. const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'stale'); assert.match(result.next_action, /stale/i); assert.equal(result.next_command, '/gsd-verify-work 01'); } finally { cleanup(baseDir); } }); test('gaps_found verification older than a summary still returns gaps_found (not stale)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); const dir = path.join(baseDir, '01-stale-gaps'); fs.mkdirSync(dir); try { const verificationPath = path.join(dir, '01-VERIFICATION.md'); const summaryPath = path.join(dir, '01-01-SUMMARY.md'); writeVerificationMd(dir, '01-VERIFICATION.md', 'gaps_found'); fs.writeFileSync(summaryPath, '# Summary'); setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); const result = readVerificationStatus(dir); assert.equal(result.status, 'gaps_found'); assert.equal(result.next_command, '/gsd-plan-phase 01 --gaps'); } finally { cleanup(baseDir); } }); test('human_needed verification older than nested plans/SUMMARY-NN.md returns stale', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); const dir = path.join(baseDir, '01-stale-human-nested'); fs.mkdirSync(dir); try { const plansDir = path.join(dir, 'plans'); fs.mkdirSync(plansDir); const verificationPath = path.join(dir, '01-VERIFICATION.md'); const summaryPath = path.join(plansDir, 'SUMMARY-01-manual.md'); writeVerificationMd(dir, '01-VERIFICATION.md', 'human_needed'); fs.writeFileSync(summaryPath, '# Summary'); setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); // git times unavailable → mtime-fallback path (#2348). const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'stale'); assert.equal(result.next_command, '/gsd-verify-work 01'); } finally { cleanup(baseDir); } }); // ── #2348: staleness derived from git commit time, not filesystem mtime ──── // // The verification staleness gate must survive a fresh `git clone` / `cp -R` // and an unrelated `touch`. It compares git commit times (content-tied) and // only falls back to mtime when a file has no commit time (uncommitted / no // repo), always reading both sides of a comparison from the same clock. // Injectable per-phase git-commit-time resolver: given the phase-relative file // names, returns Map. A file whose basename is absent from // `byBase` resolves to "no git time" (uncommitted / not in git) → mtime clock. const phaseCleanTimes = (byBase) => (_phaseDir, files) => { const m = new Map(); for (const file of files) { const base = file.split(/[\\/]/).pop(); if (Object.prototype.hasOwnProperty.call(byBase, base)) m.set(file, byBase[base]); } return m; }; // git availability for the real-subprocess integration test below. const GIT_AVAILABLE = (() => { // Soft probe — a missing/broken git binary must resolve to `false`, not // throw, so seamRunGit is used directly rather than gitOrThrow. const r = seamRunGit(['--version'], { timeoutMs: GIT_TIMEOUT_MS }); return r.outcome === OUTCOME.EXITED && r.exitCode === 0; })(); test('committed passed verification is NOT stale from mtime skew alone when the summary was not committed later (#2348)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-parent-')); const dir = path.join(baseDir, '02-clone-skew'); fs.mkdirSync(dir); try { const verificationPath = path.join(dir, '02-VERIFICATION.md'); const summaryPath = path.join(dir, '02-02-SUMMARY.md'); writeVerificationMd(dir, '02-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); // Filesystem mtimes reproduce the reported 49s checkout skew (summary newer). setMtime(verificationPath, '2026-07-16T22:53:49.000Z'); setMtime(summaryPath, '2026-07-16T22:54:38.000Z'); // But in git both were committed together — the summary is not newer. const phaseCleanCommitTimesMs = phaseCleanTimes({ '02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'), '02-02-SUMMARY.md': Date.parse('2026-07-16T22:50:00.000Z'), }); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs }); assert.equal( result.status, 'passed', 'mtime skew alone must not override a committed passing verification', ); assert.equal(result.next_command, ''); } finally { cleanup(baseDir); } }); test('committed verification IS stale when the summary was committed later, even if its mtime is older — git clock wins (#2348)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-parent-')); const dir = path.join(baseDir, '02-git-stale'); fs.mkdirSync(dir); try { const verificationPath = path.join(dir, '02-VERIFICATION.md'); const summaryPath = path.join(dir, '02-02-SUMMARY.md'); writeVerificationMd(dir, '02-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); // mtimes point the OTHER way (verification newer) to prove git is authoritative. setMtime(verificationPath, '2026-07-16T23:00:00.000Z'); setMtime(summaryPath, '2026-07-16T22:00:00.000Z'); const phaseCleanCommitTimesMs = phaseCleanTimes({ '02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'), '02-02-SUMMARY.md': Date.parse('2026-07-16T22:55:00.000Z'), // committed later }); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs }); assert.equal(result.status, 'stale'); assert.equal(result.next_command, '/gsd-verify-work 02'); } finally { cleanup(baseDir); } }); test('git-clock staleness boundary: summary committed at V-1 / V / V+1 relative to verification (#2348)', () => { const V = Date.parse('2026-07-16T22:50:00.000Z'); for (const { deltaMs, expected } of [ { deltaMs: -1, expected: 'passed' }, { deltaMs: 0, expected: 'passed' }, { deltaMs: 1, expected: 'stale' }, ]) { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-boundary-')); const dir = path.join(baseDir, '03-boundary'); fs.mkdirSync(dir); try { const verificationPath = path.join(dir, '03-VERIFICATION.md'); const summaryPath = path.join(dir, '03-03-SUMMARY.md'); writeVerificationMd(dir, '03-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); setMtime(verificationPath, '2026-07-16T22:50:00.000Z'); setMtime(summaryPath, '2026-07-16T22:50:00.000Z'); const phaseCleanCommitTimesMs = phaseCleanTimes({ '03-VERIFICATION.md': V, '03-03-SUMMARY.md': V + deltaMs, }); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs }); assert.equal( result.status, expected, `summary committed at V${deltaMs >= 0 ? '+' : ''}${deltaMs}ms should be ${expected}`, ); } finally { cleanup(baseDir); } } }); test('a committed-clean verification is stale when a summary is edited afterward (dirty) — the edit is not shadowed by the summary commit time (#2348 dirty regression)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-dirty-')); const dir = path.join(baseDir, '02-dirty-summary'); fs.mkdirSync(dir); try { const verificationPath = path.join(dir, '02-VERIFICATION.md'); const summaryPath = path.join(dir, '02-02-SUMMARY.md'); writeVerificationMd(dir, '02-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); // Verification is committed & clean at 22:50. The summary is DIRTY (edited // on disk after its commit) so it is absent from the clean-commit map and // must be timed by its mtime — a later edit at 22:54. setMtime(verificationPath, '2026-07-16T22:50:00.000Z'); // unused (clean → commit time) setMtime(summaryPath, '2026-07-16T22:54:00.000Z'); const phaseCleanCommitTimesMs = phaseCleanTimes({ '02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'), // '02-02-SUMMARY.md' intentionally omitted → treated as dirty → mtime. }); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs }); assert.equal( result.status, 'stale', 'a dirty summary edited after the verification must stale it via mtime, not be shadowed by an equal/earlier commit time', ); assert.equal(result.next_command, '/gsd-verify-work 02'); } finally { cleanup(baseDir); } }); test('both files uncommitted (no clean-commit time) fall back to mtime ordering (#2348)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-uncommitted-')); const dir = path.join(baseDir, '02-uncommitted'); fs.mkdirSync(dir); try { const verificationPath = path.join(dir, '02-VERIFICATION.md'); const summaryPath = path.join(dir, '02-02-SUMMARY.md'); writeVerificationMd(dir, '02-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); // Neither file is committed → empty clean map → pure mtime comparison. setMtime(verificationPath, '2026-07-16T23:00:00.000Z'); setMtime(summaryPath, '2026-07-16T22:00:00.000Z'); // summary older → not stale const phaseCleanCommitTimesMs = phaseCleanTimes({}); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs }); assert.equal(result.status, 'passed', 'summary older on the mtime clock → not stale'); } finally { cleanup(baseDir); } }); test('the git-commit-time resolver is invoked at most once per phase, regardless of summary count (#2348 no per-file fan-out)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-fanout-')); const dir = path.join(baseDir, '01-fanout'); fs.mkdirSync(dir); try { writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); for (const n of ['01', '02', '03']) { fs.writeFileSync(path.join(dir, `01-${n}-SUMMARY.md`), '# Summary'); } let calls = 0; let filesSeen = 0; const phaseCleanCommitTimesMs = (_phaseDir, files) => { calls += 1; filesSeen = files.length; return new Map(); }; readVerificationStatus(dir, { phaseCleanCommitTimesMs }); assert.equal(calls, 1, 'exactly one git walk for the whole phase, not one per summary file'); assert.equal(filesSeen, 4, 'the single walk receives the verification file + all 3 summaries'); } finally { cleanup(baseDir); } }); test('a phase with no summary files performs zero git walks and is never stale (#2348)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-nosummary-')); const dir = path.join(baseDir, '01-no-summary'); fs.mkdirSync(dir); try { writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); let calls = 0; const phaseCleanCommitTimesMs = () => { calls += 1; return new Map(); }; const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs }); assert.equal(result.status, 'passed'); assert.equal(calls, 0, 'no summaries → nothing can be newer → skip the git subprocess entirely'); } finally { cleanup(baseDir); } }); test( 'real git: a summary committed after the verification reads stale via the real git clock, even for a dash-named file (#2348 end-to-end + `--` argv guard)', { skip: GIT_AVAILABLE ? false : 'git binary not available' }, () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-realgit-')); const runGit = (args, extraEnv) => gitOrThrow(args, { cwd: repo, timeoutMs: GIT_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...(extraEnv || {}) }, }); const commitEnvAt = (iso) => ({ GIT_AUTHOR_DATE: iso + '+00:00', GIT_COMMITTER_DATE: iso + '+00:00' }); try { runGit(['init', '-q']); runGit(['config', 'user.email', 'test@example.com']); runGit(['config', 'user.name', 'Test']); runGit(['config', 'commit.gpgsign', 'false']); const dir = path.join(repo, '.planning', 'phases', '01-real'); fs.mkdirSync(dir, { recursive: true }); const verificationPath = path.join(dir, '01-VERIFICATION.md'); // A leading-dash filename exercises the `--` pathspec guard in the real // `git log` argv: if `--` were dropped git would read it as a flag. const summaryName = '-danger-SUMMARY.md'; const summaryPath = path.join(dir, summaryName); fs.writeFileSync(verificationPath, '---\nstatus: passed\n---\n'); runGit(['add', '--', verificationPath]); runGit(['commit', '-q', '-m', 'add verification'], commitEnvAt('2026-07-16T22:50:00')); fs.writeFileSync(summaryPath, '# Summary'); runGit(['add', '--', summaryPath]); runGit(['commit', '-q', '-m', 'add summary later'], commitEnvAt('2026-07-16T22:55:00')); // Make mtimes claim the OPPOSITE order so only the git clock can stale it. setMtime(summaryPath, '2000-01-01T00:00:00.000Z'); setMtime(verificationPath, '2030-01-01T00:00:00.000Z'); // No seam injected → the real defaultPhaseCleanCommitTimesMs / execGit path. const result = readVerificationStatus(dir); assert.equal( result.status, 'stale', 'summary committed after the verification must read stale on the real git clock, and the dash-named file must resolve through the `--` pathspec guard', ); assert.equal(result.next_command, '/gsd-verify-work 01'); } finally { cleanup(repo); } }, ); test( 'real git: a committed summary edited on disk (dirty) reads stale via mtime, not shadowed by its commit time (#2348 dirty regression, end-to-end)', { skip: GIT_AVAILABLE ? false : 'git binary not available' }, () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-realgit-dirty-')); const runGit = (args, extraEnv) => gitOrThrow(args, { cwd: repo, timeoutMs: GIT_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...(extraEnv || {}) }, }); const commitEnvAt = (iso) => ({ GIT_AUTHOR_DATE: iso + '+00:00', GIT_COMMITTER_DATE: iso + '+00:00' }); try { runGit(['init', '-q']); runGit(['config', 'user.email', 'test@example.com']); runGit(['config', 'user.name', 'Test']); runGit(['config', 'commit.gpgsign', 'false']); const dir = path.join(repo, '.planning', 'phases', '01-real'); fs.mkdirSync(dir, { recursive: true }); const verificationPath = path.join(dir, '01-VERIFICATION.md'); const summaryPath = path.join(dir, '01-01-SUMMARY.md'); fs.writeFileSync(verificationPath, '---\nstatus: passed\n---\n'); fs.writeFileSync(summaryPath, '# Summary'); // Commit BOTH together — identical commit time, so commit time alone // would read "not stale". runGit(['add', '--', verificationPath, summaryPath]); runGit(['commit', '-q', '-m', 'add phase'], commitEnvAt('2026-07-16T22:50:00')); // Edit the summary again WITHOUT committing → working tree diverges from HEAD. fs.writeFileSync(summaryPath, '# Summary edited'); setMtime(verificationPath, '2026-07-16T22:50:00.000Z'); // clean → commit time used setMtime(summaryPath, '2026-07-16T22:54:00.000Z'); // dirty → this later mtime is used const result = readVerificationStatus(dir); assert.equal( result.status, 'stale', 'a committed-then-edited (dirty) summary must read stale via mtime, not be shadowed by its now-stale commit time', ); assert.equal(result.next_command, '/gsd-verify-work 01'); } finally { cleanup(repo); } }, ); // ── #2348: default resolver two-call error handling (hermetic, injected execGit) ── const okResult = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null }); const errResult = () => ({ exitCode: 127, stdout: '', stderr: 'git: not found', signal: null, error: new Error('ENOENT'), }); const nonzeroResult = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null }); // Fake execGit dispatching on the git subcommand (args[0]). const fakeExecGit = ({ log, diff }) => (args) => { if (args[0] === 'log') return log; if (args[0] === 'diff') return diff; throw new Error(`unexpected git ${args.join(' ')}`); }; // Reverse-chronological `git log --name-only` fixture: summary newer than verification. const LOG_OUT = [ '2000', '', '.planning/phases/01-x/01-01-SUMMARY.md', '', '1000', '', '.planning/phases/01-x/01-VERIFICATION.md', ].join('\n'); const FILES = ['01-VERIFICATION.md', '01-01-SUMMARY.md']; test('resolver: parses commit times and drops a file the dirty-check reports (#2348)', () => { const map = defaultPhaseCleanCommitTimesMs( '/repo/.planning/phases/01-x', FILES, fakeExecGit({ log: okResult(LOG_OUT), diff: okResult('.planning/phases/01-x/01-01-SUMMARY.md') }), ); assert.equal(map.get('01-VERIFICATION.md'), 1000 * 1000, 'verification commit time (seconds→ms)'); assert.equal(map.has('01-01-SUMMARY.md'), false, 'dirty summary dropped → will use mtime'); }); test('resolver: clean tree (dirty-check reports nothing) keeps all commit times (#2348)', () => { const map = defaultPhaseCleanCommitTimesMs( '/repo/.planning/phases/01-x', FILES, fakeExecGit({ log: okResult(LOG_OUT), diff: okResult('') }), ); assert.equal(map.get('01-VERIFICATION.md'), 1000 * 1000); assert.equal(map.get('01-01-SUMMARY.md'), 2000 * 1000); }); test('resolver: FAILS SAFE (empty map) when the dirty-check errors after git log succeeds (#2348)', () => { const map = defaultPhaseCleanCommitTimesMs( '/repo/.planning/phases/01-x', FILES, fakeExecGit({ log: okResult(LOG_OUT), diff: errResult() }), ); assert.equal( map.size, 0, 'an inconclusive dirty-check must discard commit times so every file falls back to mtime', ); }); test('resolver: FAILS SAFE (empty map) when the dirty-check exits non-zero (#2348)', () => { const map = defaultPhaseCleanCommitTimesMs( '/repo/.planning/phases/01-x', FILES, fakeExecGit({ log: okResult(LOG_OUT), diff: nonzeroResult() }), ); assert.equal(map.size, 0); }); test('resolver: empty map (mtime fallback) when git log itself fails (#2348)', () => { const map = defaultPhaseCleanCommitTimesMs( '/repo/.planning/phases/01-x', FILES, // diff would throw if consulted — proves log-failure short-circuits before it. fakeExecGit({ log: errResult(), diff: undefined }), ); assert.equal(map.size, 0); }); // ── Task 2 (B1): ship.md gate sentinel contract anchor ──────────────────── // // The deleted tests/ship-586-verification-routing.test.cjs was the only // thing asserting that ship.md emits the PHASE_VERIFICATION_INCOMPLETE block // sentinel (its user-visible gate error key). This test re-anchors that contract. // test('ship.md still emits the PHASE_VERIFICATION_INCOMPLETE gate sentinel (contract anchor for #651 consolidation)', () => { const shipMdPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md'); const content = fs.readFileSync(shipMdPath, 'utf-8'); assert.ok( content.includes('PHASE_VERIFICATION_INCOMPLETE'), 'ship.md must contain the literal PHASE_VERIFICATION_INCOMPLETE gate sentinel. ' + 'If you renamed or removed it, update the verification routing and this contract test.', ); }); }); // ─── #3357/#3492: phase-pinned *-VERIFICATION.md resolution ────────────────── // // A phase dir can legitimately hold more than one `*-VERIFICATION.md` — the // real per-phase report (`03-VERIFICATION.md`) alongside an ad-hoc plan // worksheet (`03-CORRECTION-VERIFICATION.md`). The original "alphabetically // first" pick chose the worksheet ('C' < 'V'), and a worksheet with no // frontmatter `status:` made the whole phase read `missing` even though a // passing report sat right next to it (#3357). // // #3492 REGRESSION this block anchors: the #3357 fix's first cut preferred // ANY canonically-shaped `-VERIFICATION.md`, regardless of WHOSE token // it carried — so a stray cross-phase or sentinel-numbered canonical file // (`999-VERIFICATION.md`) could outrank the querying phase's own (possibly // non-canonical) report. THE CONTRACT (verified against the built lib): // ['12-review-VERIFICATION.md', '999-VERIFICATION.md'] resolves to // '12-review-VERIFICATION.md' for phase token '12' (was '999-…'). // ['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'] resolves to // '04-VERIFICATION.md' for phase token '04' (was '03-CORRECTION-…'). // resolveVerificationFile is the single resolver findStaleVerificationSummary, // readVerificationStatus, commands.cts's determinePhaseStatus, and both // init.cts verification_path projectors all call, every one pinned to its own // phaseDir's token (#3473 F2 / #3492). // // These resolveVerificationFile unit tests are the RELIABLE ANCHORS for the // phase-pinned rule (a real `phaseToken` string, no filesystem/readdir order // involved). The readVerificationStatus/findStaleVerificationSummary // behavioral tests further down are illustrative only — their outcome // additionally depends on the temp directory's basename tokenizing the way // the test expects. describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple candidates exist', () => { test('#3492 regression counterexample 1: a sentinel-numbered stray file does not outrank this phase\'s own non-canonical report', () => { assert.equal( resolveVerificationFile( ['12-review-VERIFICATION.md', '999-VERIFICATION.md'], { phaseToken: '12-review' }, ), '12-review-VERIFICATION.md', 'this phase (token "12-review") owns 12-review-VERIFICATION.md; 999-VERIFICATION.md is a different phase and must not win', ); }); test('#3492 regression counterexample 2: a cross-phase CORRECTION worksheet does not outrank this phase\'s own canonical report', () => { assert.equal( resolveVerificationFile( ['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'], { phaseToken: '04' }, ), '04-VERIFICATION.md', 'this phase (token "04") owns 04-VERIFICATION.md; the 03-CORRECTION worksheet belongs to a different phase', ); }); test('exact phase-token match wins over an ad-hoc -CORRECTION- worksheet for the SAME phase', () => { assert.equal( resolveVerificationFile( ['03-CORRECTION-VERIFICATION.md', '03-VERIFICATION.md'], { phaseToken: '03' }, ), '03-VERIFICATION.md', 'the phase\'s own 03-VERIFICATION.md must win over its CORRECTION worksheet, not lose alphabetically', ); }); test('order-independence: same candidates reversed → same answer', () => { assert.equal( resolveVerificationFile( ['03-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'], { phaseToken: '03' }, ), '03-VERIFICATION.md', 'input order must not change which file is selected', ); }); test('decimal phase token: 35.1-VERIFICATION.md wins over a -CORRECTION- sibling', () => { assert.equal( resolveVerificationFile( ['35.1-CORRECTION-VERIFICATION.md', '35.1-VERIFICATION.md'], { phaseToken: '35.1' }, ), '35.1-VERIFICATION.md', ); }); test('letter-suffixed phase token: 03A-VERIFICATION.md wins over a -CORRECTION- sibling', () => { assert.equal( resolveVerificationFile( ['03A-CORRECTION-VERIFICATION.md', '03A-VERIFICATION.md'], { phaseToken: '03A' }, ), '03A-VERIFICATION.md', ); }); test('multi-canonical tiebreak: no exact phase-token match among several canonically-shaped candidates → alphabetically first', () => { // Neither candidate's token is "50" — this is the (b) fallback tier, and // it must stay a plain alphabetical pick (not a second, separate // "canonical-shaped" preference — that concept no longer exists; #3492 // removed it because it was exactly the regression mechanism above). assert.equal( resolveVerificationFile( ['12-VERIFICATION.md', '999-VERIFICATION.md'], { phaseToken: '50' }, ), '12-VERIFICATION.md', '"12-VERIFICATION.md" sorts before "999-VERIFICATION.md" and neither matches phase token "50"', ); }); test('fallback: only a non-canonical file present → that file is still returned', () => { // Load-bearing: a phase whose only report is non-canonically named must // keep resolving to it, not to null — even when the phase token is known // and does not exactly match. assert.equal( resolveVerificationFile(['01-review-VERIFICATION.md'], { phaseToken: '01' }), '01-review-VERIFICATION.md', ); }); test('fallback determinism: several non-canonical files, no phase token given → alphabetically first (unchanged)', () => { assert.equal( resolveVerificationFile(['02-b-VERIFICATION.md', '01-a-VERIFICATION.md']), '01-a-VERIFICATION.md', ); }); test('no phaseToken and no exact match → falls back to alphabetically-first, never null, when candidates exist', () => { // #3492: an undeliverable/absent phase token must degrade to the original // pre-#3357 behavior (alphabetically-first), not to null. assert.equal( resolveVerificationFile(['999-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md']), '03-CORRECTION-VERIFICATION.md', 'with no phaseToken, plain alphabetical order decides — "03-…" sorts before "999-…"', ); }); test('no matches → null', () => { assert.equal(resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md'], { phaseToken: '03' }), null); }); test('unrelated files are not miscounted as candidates', () => { // 03-PLAN.md / 03-SUMMARY.md never end in "-VERIFICATION.md". A bare // "VERIFICATION.md" (no leading phase-token dash) is also never a // candidate — it fails the very `.endsWith('-VERIFICATION.md')` filter // that builds the candidate list in the first place (the string is one // character too short to end with a leading-dash suffix). assert.equal( resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md', 'VERIFICATION.md'], { phaseToken: '03' }), null, 'a bare VERIFICATION.md is never a dashed candidate', ); }); // #3511 reconciliation: resolveVerificationFile's fallback now scopes to // isPhaseArtifact(fileName, phaseDirName), so a stray cross-phase file can // no longer win the alphabetical-first fallback tier either — closing the // gap isPhaseArtifact's own docblock (src/phase-id.cts) used to flag as // open. The two pure cases below are the reliable anchors; the behavioral // test after them pins the same contract through the real CLI-facing // readVerificationStatus call path. test('#3511: a cross-phase stray is excluded from the fallback → null, not the stray', () => { assert.equal( resolveVerificationFile(['04-VERIFICATION.md'], { phaseDirName: '03-foo' }), null, '04-VERIFICATION.md belongs to phase 04, not the "03-foo" directory\'s phase 03 — must not be returned', ); }); test('#3511: a non-canonically-named report OF THIS phase still wins the fallback (the #3357 guarantee survives)', () => { // The more important of the two #3511 cases: isPhaseArtifact scopes by // phase-number membership, not by canonical shape, so this file still // passes and the #3357 "non-canonical report still resolves" guarantee // is not disturbed by the #3511 scoping. assert.equal( resolveVerificationFile(['03-CORRECTION-VERIFICATION.md'], { phaseDirName: '03-foo' }), '03-CORRECTION-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md names phase 03, same as directory "03-foo" — must still resolve', ); }); test('#3511: cross-phase stray alongside this phase\'s own non-canonical report → own report wins, stray excluded (not merely outsorted)', () => { // Distinguishes "excluded from the fallback" from "just happens to sort // after" — candidates are sorted at verification.cts's own call site // before reaching resolveVerificationFile, and '01-VERIFICATION.md' // sorts BEFORE '03-CORRECTION-VERIFICATION.md' alphabetically, so an // UNSCOPED (alphabetical-first) fallback would wrongly pick the stray // here. Scoping must actively exclude it for '03-CORRECTION-…' to win. assert.equal( resolveVerificationFile( ['01-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'], { phaseDirName: '03-foo' }, ), '03-CORRECTION-VERIFICATION.md', ); }); // WARNING-2/5/INFO-2 note (#3511 review): the only fallback test above uses // a token-LESS dir ("03-foo" isn't token-less — this refers to the earlier // `multiple *-VERIFICATION.md files, none matching the phase token` test, // which passes no derivable-token distinguishing fixture and so passes // identically pre-#3511-fix). This test uses a dir WITH a derivable token // (`03-foo` → token "03") and TWO candidates that BOTH belong to that same // phase (`03-a-…`/`03-b-…`, no exact `03-VERIFICATION.md`), so scoping // excludes nothing and the alphabetical-first tie-break still decides — // pinning that scoping does not disturb the ordinary same-phase-multi-file // case. test('#3511: alphabetical fallback when BOTH candidates are this phase\'s own (derivable token, no exact match)', () => { assert.equal( resolveVerificationFile(['03-a-VERIFICATION.md', '03-b-VERIFICATION.md'], { phaseDirName: '03-foo' }), '03-a-VERIFICATION.md', 'both candidates belong to phase 03 (same as dir "03-foo"); alphabetically-first must still win', ); }); test('behavioral (readVerificationStatus): a phase dir holding only a cross-phase stray reports missing, not the stray\'s status (#3511)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-stray-only-')); const dir = path.join(baseDir, '03-foo'); fs.mkdirSync(dir); try { // Only a stray belonging to phase 04 sits in phase 03's directory. Give // it a status that would NOT read as missing if it were (wrongly) picked, // so a regression here is loud rather than accidentally matching. writeVerificationMd(dir, '04-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir); assert.equal( result.status, 'missing', 'a phase dir holding only another phase\'s report must report missing, not passed', ); } finally { cleanup(baseDir); } }); test('behavioral (readVerificationStatus): a phase dir holding only its own non-canonically-named report still resolves it (#3357 guarantee survives #3511 scoping)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-own-noncanon-')); const dir = path.join(baseDir, '03-foo'); fs.mkdirSync(dir); try { writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir); assert.equal( result.status, 'passed', 'the phase\'s own non-canonically-named report must still resolve, not read as missing', ); } finally { cleanup(baseDir); } }); test('behavioral (readVerificationStatus): a phase with both its own report and a cross-phase stray reports the OWN report\'s status, not the stray\'s', () => { // The directory basename is "03-canonical-test" so extractPhaseToken // derives token "03" — the exact same derivation readVerificationStatus // performs internally, so this exercises the real production call path // (not just the pure resolver), pinned to counterexample 2's shape. const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-parent-')); const dir = path.join(baseDir, '03-canonical-test'); fs.mkdirSync(dir); try { // A stray cross-phase canonical file with a DIFFERENT status — must not // be picked for THIS (token "03") phase. writeVerificationMd(dir, '99-VERIFICATION.md', 'gaps_found'); // This phase's own (non-canonical, ad-hoc) report — must win. writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir); assert.equal( result.status, 'passed', 'the phase must report its OWN report\'s status, not a cross-phase stray\'s', ); } finally { cleanup(baseDir); } }); test('behavioral (readVerificationStatus): a phase with both its own canonical report and an ad-hoc worksheet reports the canonical report\'s status, not missing (#3357 original regression)', () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3357-parent-')); const dir = path.join(baseDir, '03-canonical-test'); fs.mkdirSync(dir); try { // The ad-hoc worksheet has no frontmatter `status:` at all — this is // the exact original #3357 failure mode: 'C' < 'V' picked this file // first and the phase read 'missing' despite the passing report sitting // right next to it. fs.writeFileSync( path.join(dir, '03-CORRECTION-VERIFICATION.md'), '# Ad-hoc correction worksheet\n\nNo frontmatter status here.\n', ); writeVerificationMd(dir, '03-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir); assert.equal( result.status, 'passed', 'the phase must report the canonical report\'s status, not missing', ); } finally { cleanup(baseDir); } }); test('behavioral (findStaleVerificationSummary): staleness is checked against THIS phase\'s own report, not a cross-phase stray', () => { // A stray cross-phase file ("99-VERIFICATION.md") is alphabetically AFTER // this phase's own "03-VERIFICATION.md", so this also demonstrates the // pin is not merely riding on alphabetical luck. const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-stale-parent-')); const dir = path.join(baseDir, '03-stale-pin-test'); fs.mkdirSync(dir); try { writeVerificationMd(dir, '03-VERIFICATION.md', 'passed'); writeVerificationMd(dir, '99-VERIFICATION.md', 'passed'); setMtime(path.join(dir, '03-VERIFICATION.md'), '2020-01-01T00:00:00Z'); setMtime(path.join(dir, '99-VERIFICATION.md'), '2020-01-01T00:00:00Z'); // Root-style summary placement (mirrors the #2348 fixtures above) — // scanPhasePlans's nested-layout matcher requires `SUMMARY-...md` // inside a `plans/` subdir; a root-named `03-01-SUMMARY.md` dropped into // `plans/` matches neither isRootSummaryFile (wrong directory) nor // isNestedSummaryFile (wrong filename shape), so summaryFiles reads // empty and the phase is never stale — not what this test means to // exercise. const summaryPath = path.join(dir, '03-01-SUMMARY.md'); fs.writeFileSync(summaryPath, '# summary\n'); setMtime(summaryPath, '2021-01-01T00:00:00Z'); // Force the mtime path (no git clock) by injecting an empty resolver — // mirrors the existing #2348 test pattern elsewhere in this file. const result = findStaleVerificationSummary(dir, fs, () => new Map()); assert.equal(result.determined, true); assert.equal(result.stale, true, 'the phase\'s own 03-VERIFICATION.md is older than its summary'); assert.equal( result.verificationFile, '03-VERIFICATION.md', 'staleness must be computed against the phase\'s own report, not the cross-phase 99-VERIFICATION.md stray', ); } finally { cleanup(baseDir); } }); }); // ─── #3473 F2: resolveVerificationFile allowBare option ────────────────────── // // commands.cts (determinePhaseStatus) and two verification_path projectors in // init.cts each hand-rolled a fourth variant of this same selection: they // additionally accept a BARE `VERIFICATION.md`, which this module's own two // callers (findStaleVerificationSummary, readVerificationStatus) never have. // `allowBare` threads that one behavioral difference through the single // resolver instead of leaving a fourth hand-rolled implementation behind // (#3473 F2). A bare match is ranked BELOW any dashed candidate — canonical // or not — because a dashed file names its phase and a bare one does not. describe('#3473 F2: resolveVerificationFile allowBare option', () => { test('allowBare defaults to false — a bare-only list returns null without the option', () => { assert.equal(resolveVerificationFile(['VERIFICATION.md']), null); }); test('allowBare:true, bare-only candidate → bare file returned', () => { assert.equal( resolveVerificationFile(['VERIFICATION.md'], { allowBare: true }), 'VERIFICATION.md', ); }); test('allowBare:true, bare + non-canonical dashed → the dashed fallback wins', () => { assert.equal( resolveVerificationFile( ['VERIFICATION.md', '01-review-VERIFICATION.md'], { allowBare: true }, ), '01-review-VERIFICATION.md', 'a dashed non-canonical file names its phase and must win over a bare match', ); }); test('allowBare:true, bare + canonical → the canonical file wins', () => { assert.equal( resolveVerificationFile( ['VERIFICATION.md', '03-VERIFICATION.md'], { allowBare: true }, ), '03-VERIFICATION.md', ); }); // #3511: allowBare must still fall through to the bare match when the ONLY // dashed candidate is excluded by phaseDirName scoping (a cross-phase // stray) — the fallback tier finding nothing phase-owned is the same // "no dashed candidate at all" case allowBare was always reached from. test('#3511: allowBare:true, bare + a cross-phase dashed stray scoped out by phaseDirName → the bare file wins', () => { assert.equal( resolveVerificationFile( ['VERIFICATION.md', '04-VERIFICATION.md'], { allowBare: true, phaseDirName: '03-foo' }, ), 'VERIFICATION.md', '04-VERIFICATION.md belongs to a different phase and is excluded, so bare VERIFICATION.md is the only remaining candidate', ); }); }); // ─── #3518: resolveUatFile — phase-pinned, deterministic *-UAT.md pick ─────── // // Both uat_path projectors in src/init.cts picked the phase's UAT artifact // with a bare `.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md')` over an // unsorted readdir listing: no phase-membership check, no ordering. A stray // cross-phase 04-UAT.md in phase 03's directory could become phase 03's // uat_path, and WHICH file won was filesystem-dependent (creation order on // APFS, hash order on ext4/XFS) — two machines on the same commit could emit // different uat_path values for the same phase (#3518). // // resolveUatFile is the UAT counterpart of resolveVerificationFile, sharing // the identical selection rule via the resolvePhaseArtifactFile core: the // phase's own -UAT.md always wins; otherwise alphabetically-first // dashed candidate (deterministic on every filesystem); a bare UAT.md only // when allowBare is set and no dashed candidate exists at all. // // These unit tests are the RELIABLE ANCHORS for the rule (a real phaseToken // string, no readdir order involved). The end-to-end red/green repro for the // two init.cts projector call sites lives in tests/init.test.cjs (#3518). describe('#3518: resolveUatFile — phase-pinned *-UAT.md resolution', () => { test('#3518 regression: a stray cross-phase -UAT.md does not outrank this phase\'s own UAT file', () => { assert.equal( resolveUatFile( ['04-UAT.md', '03-UAT.md'], { phaseToken: '03' }, ), '03-UAT.md', 'this phase (token "03") owns 03-UAT.md; the stray 04-UAT.md belongs to a different phase', ); }); test('order-independence: same candidates reversed → same answer', () => { assert.equal( resolveUatFile( ['03-UAT.md', '04-UAT.md'], { phaseToken: '03' }, ), '03-UAT.md', 'input order must not change which file is selected', ); }); test('fallback: only a stray cross-phase file present → still returned, never null', () => { // Load-bearing: a phase whose only UAT artifact is not its own // canonically-named file must keep resolving to SOMETHING, not to null — // deterministically (alphabetically-first) rather than by readdir order. assert.equal( resolveUatFile(['04-UAT.md', '02-UAT.md'], { phaseToken: '03' }), '02-UAT.md', '"02-UAT.md" sorts before "04-UAT.md" — deterministic even when the phase\'s own file is absent', ); }); test('fallback determinism: several candidates, no phase token given → alphabetically first', () => { assert.equal(resolveUatFile(['02-UAT.md', '01-a-UAT.md']), '01-a-UAT.md'); }); test('allowBare defaults to false — a bare-only list returns null without the option', () => { assert.equal(resolveUatFile(['UAT.md']), null); }); test('allowBare:true, bare + dashed → the dashed candidate wins', () => { assert.equal( resolveUatFile(['UAT.md', '03-UAT.md'], { allowBare: true, phaseToken: '03' }), '03-UAT.md', 'a dashed file names its phase and must win over a bare match', ); }); test('allowBare:true, bare-only candidate → bare file returned', () => { assert.equal(resolveUatFile(['UAT.md'], { allowBare: true }), 'UAT.md'); }); test('no matches → null', () => { assert.equal(resolveUatFile(['03-PLAN.md', '03-SUMMARY.md'], { phaseToken: '03' }), null); }); }); // ─── #3518: call-site guard — no hand-rolled *-UAT.md single-pick survives ──── // // The "Partial Fix Across Call Sites" regression class: a future contributor // adding a NEW uat_path-style projection (or reverting one of the two fixed // init.cts sites) would hand-roll `.find((f) => f.endsWith('-UAT.md') || // f === 'UAT.md')` again — reintroducing the readdir-order, // no-phase-check pick #3518 closed. This scans src/ for that literal shape // and fails on any site outside src/verification.cts, whose // resolvePhaseArtifactFile core is the single owner of the pattern. // (src/commands.cts's scaffold WRITER builds `${padded}-UAT.md` directly — // a canonical-name construction, not a discovery pick — and does not match.) describe('#3518: call-site guard — every *-UAT.md single-pick routes through resolveUatFile', () => { test('no hand-rolled -UAT.md discovery pick exists outside src/verification.cts', () => { const srcDir = path.join(__dirname, '..', 'src'); const owner = path.join(srcDir, 'verification.cts'); // The two shapes the pre-#3518 bug appeared as: an endsWith('-UAT.md') // predicate, or a bare `=== 'UAT.md'` equality, anywhere in src/. const HAND_ROLLED_RE = /endsWith\(['"`]-UAT\.md['"`]\)|===\s*['"`]UAT\.md['"`]/; const offenders = []; for (const file of fs.readdirSync(srcDir)) { if (!file.endsWith('.cts')) continue; const fullPath = path.join(srcDir, file); if (fullPath === owner) continue; // CRLF-tolerant split (local/no-crlf-fragile-split): Windows // git-autocrlf checkouts yield \r\n line endings. const lines = fs.readFileSync(fullPath, 'utf-8').split(/\r?\n/); lines.forEach((line, i) => { if (HAND_ROLLED_RE.test(line)) offenders.push(`src/${file}:${i + 1}: ${line.trim()}`); }); } assert.deepStrictEqual( offenders, [], 'hand-rolled *-UAT.md single-pick(s) — route through resolveUatFile ' + '(src/verification.cts, issue #3518):\n' + offenders.join('\n'), ); }); }); // ─── #3057 B3: findStaleVerificationSummary — indeterminate vs not-stale ───── // // The pre-fix catch-all returned `null` on ANY fs / scanPhasePlans / clock // failure — identical to a completed check that genuinely found nothing // stale. `opts.fs` had never been exercised by any test. These two tests // confirm (a) the `opts.fs` injection seam actually works, and (b) the two // outcomes are now distinguishable via `staleCheckIndeterminate` on the // `readVerificationStatus` result. describe('#3057 B3: staleness check — indeterminate is distinguishable from not-stale', () => { test('an fs failure inside the staleness check yields staleCheckIndeterminate:true, not a silent "not stale"', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3057-b3-fault-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-stale-check-fault'); fs.mkdirSync(dir); const verificationPath = path.join(dir, '01-VERIFICATION.md'); const summaryPath = path.join(dir, '01-01-SUMMARY.md'); writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); // The summary IS newer — if the check ran to completion it would find // 'stale'. The point of this test is that it never gets to find out. setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); // Confirms opts.fs is actually threaded through: readdirSync/readFileSync // delegate to the real fs (so "find the VERIFICATION.md" / "read its // frontmatter" upstream of the staleness check still succeed normally), // and ONLY statSync is faulted — driving findStaleVerificationSummary's // catch branch specifically, via the injected seam, not a global monkeypatch. const fsLike = { readdirSync: (d) => fs.readdirSync(d), readFileSync: (p, enc) => fs.readFileSync(p, enc), statSync: () => { throw new Error('injected stat failure (#3057 B3)'); }, }; const result = readVerificationStatus(dir, { fs: fsLike, phaseCleanCommitTimesMs: () => new Map(), }); // Pre-existing no-throw fail-open contract is UNCHANGED: routing still // proceeds as if nothing were stale (status stays 'passed', not 'stale' — // a genuinely-stale summary sits right there and would have tripped the // 'stale' route had the check run to completion). assert.equal(result.status, 'passed'); // But the cause is no longer silently identical to a completed "nothing // is stale" check — this MUST be flagged as indeterminate. assert.strictEqual(result.staleCheckIndeterminate, true); }); test('a completed staleness check that finds nothing stale never reports indeterminate', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3057-b3-ok-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-stale-check-ok'); fs.mkdirSync(dir); const verificationPath = path.join(dir, '01-VERIFICATION.md'); const summaryPath = path.join(dir, '01-01-SUMMARY.md'); writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); fs.writeFileSync(summaryPath, '# Summary'); // Verification NEWER than the summary → the check runs to completion // (no fault injected) and genuinely finds nothing stale. setMtime(summaryPath, '2026-01-01T00:00:00.000Z'); setMtime(verificationPath, '2026-01-01T00:01:00.000Z'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'passed'); assert.strictEqual( result.staleCheckIndeterminate, undefined, 'a completed check that found nothing stale must not be flagged indeterminate', ); }); }); // ─── #2617: next_command runtime projection ────────────────────────────────── // // Regression tests for #2617 — verification-status `next_command` bypassed the // runtime command-surface projection. // // `src/verification.cts` stored and synthesized hard-coded `/gsd:…` strings with // no runtime context, and `phase complete` relayed that raw field straight into // its verification-blocked error. On a Codex project the suggested next step was // `/gsd:execute-phase`, which Codex does not install — the surface there is // `$gsd-execute-phase`. The colon form is doubly wrong: `runtime-slash.cts` // documents that "the colon form is never emitted", so every runtime was getting // a deprecated shape. (The 11 `/gsd-…` assertions above were `/gsd:…` before this // fix — they are the failing-first record.) // // The fix keeps ONE routing seam and makes its emitted command runtime-aware: // the table stores bare command names and every return path projects through // `formatGsdSlash`, with callers passing `resolveRuntime(cwd)`. // // Coverage is the matrix the issue asked for — missing, unknown, gaps_found and // stale, against Codex (`$gsd-…`) and a slash-hyphen runtime (`/gsd-…`) — plus // the `phase complete` error path, not merely the router's return object. /** Codex installs `$gsd-`; every other shipped runtime installs `/gsd-`. */ const RUNTIMES = [ { id: 'codex', prefix: '$gsd-' }, { id: 'cursor', prefix: '/gsd-' }, ]; // NOTE: deliberately NOT file-scope beforeEach/afterEach. node:test applies // module-scope hooks to EVERY test in the file, so hooks added here for the // #2617 suites would also wrap the ~40 pre-existing tests above — making this // block a single point of failure for suites it has nothing to do with. Each // test allocates and releases its own phase dir instead. let projBaseDir; let projPhaseDir; /** * Install the #2617 temp-phase-dir lifecycle INSIDE the calling describe. * node:test scopes hooks to their enclosing describe, so this keeps them off the * ~40 pre-existing tests in this file. */ function useProjectionPhaseDir() { beforeEach(() => { projBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2617-')); projPhaseDir = path.join(projBaseDir, '01-example'); fs.mkdirSync(projPhaseDir, { recursive: true }); }); afterEach(() => cleanup(projBaseDir)); } const verificationPath = () => path.join(projPhaseDir, '01-VERIFICATION.md'); function writeStatus(status) { fs.writeFileSync(verificationPath(), `---\nstatus: ${status}\n---\n\n# Verification\n`); } function removeVerification() { try { fs.unlinkSync(verificationPath()); } catch { /* already absent */ } } /** Make the verification file older than a summary → the stale branch. */ function makeStale() { const summaryPath = path.join(projPhaseDir, '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, '# Summary\n'); fs.utimesSync(verificationPath(), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z')); fs.utimesSync(summaryPath, new Date('2026-01-01T00:01:00Z'), new Date('2026-01-01T00:01:00Z')); } // git times unavailable → mtime-fallback path (#2348). Injected so the staleness // clock stays hermetic regardless of the tmpdir's repo state. const NO_GIT = { phaseCleanCommitTimesMs: () => new Map() }; function read(runtime, extra = {}) { return readVerificationStatus(projPhaseDir, { runtime, ...extra }); } for (const { id, prefix } of RUNTIMES) { describe(`#2617: next_command uses the ${id} command surface`, () => { useProjectionPhaseDir(); test('missing verification', () => { removeVerification(); assert.equal(read(id).next_command, `${prefix}execute-phase 01`); }); test('unparseable/absent frontmatter status is also "missing"', () => { fs.writeFileSync(verificationPath(), '# Verification\n\nNo frontmatter here.\n'); assert.equal(read(id).next_command, `${prefix}execute-phase 01`); }); test('unknown status value', () => { writeStatus('not-a-real-status'); const result = read(id); assert.equal(result.status, 'unknown'); assert.equal(result.next_command, `${prefix}execute-phase 01`); }); test('gaps_found carries the phase number and --gaps flag through the projection', () => { writeStatus('gaps_found'); const result = read(id); assert.equal(result.status, 'gaps_found'); assert.equal(result.next_command, `${prefix}plan-phase 01 --gaps`); }); test('stale carries the phase number through the projection', () => { writeStatus('passed'); makeStale(); const result = read(id, NO_GIT); assert.equal(result.status, 'stale'); assert.equal(result.next_command, `${prefix}verify-work 01`); }); test('passed has no next step and stays empty, not a bare prefix', () => { // Boundary: projecting an empty command must not emit `$gsd-` / `/gsd-`. writeStatus('passed'); assert.equal(read(id).next_command, '', 'passed has no next command and must project to the empty string'); }); test('human_needed names the verify-work command its next_action describes', () => { // #2617 unification: the table used to return '' here while init.cts's // parallel projector returned `verify-work ` for the same state — the // two surfaces disagreed on whether a next command existed at all. writeStatus('human_needed'); assert.equal(read(id).next_command, `${prefix}verify-work 01`); }); }); } describe('#2617: no verification output suggests the deprecated colon form', () => { useProjectionPhaseDir(); test('across every state and runtime, and for the default runtime', () => { const runtimeIds = [...RUNTIMES.map((r) => r.id), undefined]; let checked = 0; for (const runtime of runtimeIds) { const opts = runtime === undefined ? { ...NO_GIT } : { runtime, ...NO_GIT }; removeVerification(); const cases = [readVerificationStatus(projPhaseDir, opts)]; for (const status of ['not-a-real-status', 'gaps_found', 'passed', 'human_needed']) { writeStatus(status); cases.push(readVerificationStatus(projPhaseDir, opts)); } writeStatus('passed'); makeStale(); cases.push(readVerificationStatus(projPhaseDir, opts)); for (const result of cases) { assert.ok( !result.next_command.includes('/gsd:'), `deprecated colon form leaked for runtime=${String(runtime)}: ${result.next_command}`, ); checked++; } } // Non-vacuity: 3 runtimes x 6 states. assert.equal(checked, 18, 'expected every runtime x state combination to be checked'); }); test('the default runtime yields the canonical hyphen form, not the colon form', () => { removeVerification(); // No `runtime` option at all — the pre-fix default emitted `/gsd:execute-phase`. assert.equal(readVerificationStatus(projPhaseDir).next_command, '/gsd-execute-phase 01'); }); }); describe('#2617: the phase-complete error path projects too', () => { // The issue is explicit that fixing only the router is insufficient: the // user-visible surface is `phase complete`, which relays next_command into its // blocked-completion error. Driven through the real CLI so the assertion is on // what a user actually sees. const { runGsdTools, createTempGitProject } = require('./helpers.cjs'); for (const { id, prefix } of RUNTIMES) { test(`phase complete on ${id} suggests ${prefix}execute-phase`, () => { const projectDir = createTempGitProject(); try { fs.writeFileSync( path.join(projectDir, '.planning', 'config.json'), JSON.stringify({ runtime: id }, null, 2), ); const phase = path.join(projectDir, '.planning', 'phases', '01-example'); fs.mkdirSync(phase, { recursive: true }); // No *-VERIFICATION.md → the completion gate blocks with reason "missing". const res = runGsdTools(['phase', 'complete', '01'], projectDir); // The blocked-completion message goes to stderr, which runGsdTools // surfaces as `error` (NOT `stderr`) on a clean non-zero exit. Reading // the wrong field yields '' and makes every assertion below vacuous. const text = `${res.output || ''}${res.error || ''}`; assert.equal(res.success, false, 'completion must be blocked with no verification report'); assert.match( text, /verification is incomplete/i, `expected the blocked-completion error, got: ${text}`, ); // Unconditional — a conditional check here passes when the command is // absent entirely, which is exactly how this path stayed untested. assert.ok( text.includes(`${prefix}execute-phase`), `phase complete must suggest ${prefix}execute-phase on ${id}, got: ${text}`, ); assert.ok( !text.includes('/gsd:'), `phase complete must not surface the deprecated colon form: ${text}`, ); } finally { cleanup(projectDir); } }); test(`phase complete on ${id} projects the gaps_found command too`, () => { // Finding from review: the live-CLI check previously exercised only the // `missing` state, so a regression in any other routed branch would show // up in the router's return object but not in what a user actually reads. const projectDir = createTempGitProject(); try { fs.writeFileSync( path.join(projectDir, '.planning', 'config.json'), JSON.stringify({ runtime: id }, null, 2), ); const phase = path.join(projectDir, '.planning', 'phases', '01-example'); fs.mkdirSync(phase, { recursive: true }); fs.writeFileSync( path.join(phase, '01-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n\n# Verification\n', ); const res = runGsdTools(['phase', 'complete', '01'], projectDir); const text = `${res.output || ''}${res.error || ''}`; assert.equal(res.success, false, 'gaps_found must block completion'); assert.ok( text.includes(`${prefix}plan-phase 01 --gaps`), `phase complete must suggest ${prefix}plan-phase 01 --gaps on ${id}, got: ${text}`, ); assert.ok(!text.includes('/gsd:'), `deprecated colon form leaked: ${text}`); } finally { cleanup(projectDir); } }); } }); // ─── #2868: stranded-phase detection via `verification status` ──────────────── // // execute-phase's `discover_and_group_plans` step resumes at the phase gates // when every plan is summarized but no *-VERIFICATION.md exists yet. That // resume decision is driven by `gsd_run query verification status // --pick status` reading `missing`. These tests pin the CLI query's behavior // on the exact fixture shapes the workflow branches on, via the real CLI // (runGsdTools), not the in-process readVerificationStatus() helper used above. describe('#2868: verification status CLI drives the execute-phase stranded-phase resume', () => { const { runGsdTools, createTempGitProject } = require('./helpers.cjs'); test('D1: all plans summarized, no *-VERIFICATION.md → status is missing', () => { const projectDir = createTempGitProject(); try { const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); const res = runGsdTools(['verification', 'status', phaseDir, '--pick', 'status'], projectDir); assert.equal(res.success, true, `verification status should succeed: ${res.error}`); assert.equal(res.output, 'missing', 'no VERIFICATION.md at all → status must be missing'); } finally { cleanup(projectDir); } }); test('D2: same fixture plus a passed *-VERIFICATION.md → status is not missing', () => { const projectDir = createTempGitProject(); try { const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync( path.join(phaseDir, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n', ); const res = runGsdTools(['verification', 'status', phaseDir, '--pick', 'status'], projectDir); assert.equal(res.success, true, `verification status should succeed: ${res.error}`); assert.notEqual(res.output, 'missing', 'a passed VERIFICATION.md must not read as missing'); assert.equal(res.output, 'passed'); } finally { cleanup(projectDir); } }); test('D3: one plan lacking a SUMMARY and no verification → still missing (not conflated with "stranded")', () => { const projectDir = createTempGitProject(); try { const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1\n'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1\n'); // 01-02 has a PLAN but no SUMMARY — plan work is still outstanding, which is // a different condition from the phase being "stranded" (all plans done, // verification never ran). The query must not conflate the two. fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2\n'); const res = runGsdTools(['verification', 'status', phaseDir, '--pick', 'status'], projectDir); assert.equal(res.success, true, `verification status should succeed: ${res.error}`); assert.equal( res.output, 'missing', 'outstanding plan work must not change verification status away from missing', ); } finally { cleanup(projectDir); } }); }); { const { describe: __foldDescribe } = require('node:test'); __foldDescribe('folded:fix-3174-quick-verification-status-read', () => { // allow-test-rule: source-text-is-the-product see #3174 // Workflow .md / agent .md / command .md / reference .md files — their text // IS what the runtime loads. Testing text content tests the deployed contract. // Per CONTRIBUTING.md exception matrix. // // #3174: quick's verification step used to read the verifier's result with a // raw `grep "^status:" F | cut -d: -f2 | tr -d ' '` and route it through arms // passed / human_needed / gaps_found only. That read failed two ways, both // measured against the old pipeline: it matched NO arm on a missing report, // most off-schema values, a `status:` line in both frontmatter and prose, or // (on a CRLF checkout) a valid `passed` arriving as `passed\r`; and it // matched the SUCCESS arm when it should not have on a stale `passed` // report (staleness was never evaluated), a report whose only `status:` line // sits in its prose, or an off-schema value carrying a colon // (`passed:bogus`), which `cut -d: -f2` splits at that colon. The unanchored // match is the DEFECT.FRONTMATTER-SCALAR-BROAD-GREP class the code side // already fixed by name. These tests pin the five properties that keep the // replacement honest. describe('quick verification status read (#3174)', () => { const QUICK_VERIFICATION = path.join( __dirname, '..', 'gsd-core', 'workflows', 'quick', 'steps', 'quick-verification.md', ); // The canonical launcher preamble. scripts/sync-runtime-launcher.cjs rewrites // every workflow's bootstrap from this file, so THIS is the authority — not // whichever sibling step file happens to carry a copy today. const LAUNCHER_SNIPPET = path.join( __dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh', ); const SHIM_ANCHOR = '_GSD_SHIM_NAME="gsd-tools.cjs"'; test('status is read through the canonical query, not a raw frontmatter grep', () => { const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8'); const queryIdx = content.indexOf('gsd_run query verification.status "${QUICK_DIR}"'); assert.ok(queryIdx !== -1, 'quick-verification.md must read status via the verification.status query'); assert.ok( !content.includes('grep "^status:"'), 'the raw frontmatter-scalar grep must not return — it matches body lines too (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP)', ); }); test('the query call is preceded by the runtime shim bootstrap in this step file', () => { // Step files are read and executed as their own units, so quick.md's // bootstrap does not reach here. Without this the call resolves to // nothing, 2>/dev/null swallows it, and the default arm is taken forever. const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8'); const shimIdx = content.indexOf(SHIM_ANCHOR); const queryIdx = content.indexOf('gsd_run query verification.status'); assert.ok(shimIdx !== -1, 'the step file must carry its own runtime shim bootstrap'); assert.ok(queryIdx > shimIdx, 'the shim bootstrap must precede the gsd_run call'); }); test('the shim bootstrap is the canonical launcher preamble, not a fork of it', () => { // Anchored on _runtime-launcher.snippet.sh rather than on a sibling step // file: sync-runtime-launcher.cjs regenerates every workflow from the // snippet, so a synchronized launcher update keeps this green (correct), // and a sibling that legitimately stops calling gsd_run cannot fail us. const lineWithShim = (file) => fs.readFileSync(file, 'utf-8') .split(/\r?\n/) .find((line) => line.startsWith(SHIM_ANCHOR)); const mine = lineWithShim(QUICK_VERIFICATION); const canonical = lineWithShim(LAUNCHER_SNIPPET); assert.ok(canonical, '_runtime-launcher.snippet.sh must carry the canonical preamble'); assert.equal(mine, canonical, 'the bootstrap must match the canonical launcher snippet verbatim'); }); test('status extraction does not depend on jq', () => { // #2589: a `| jq -r '.field'` pipe yields an empty variable with no // diagnostic wherever jq is absent (the Windows/Git-Bash default), which // would route a passing verification into the recovery arm. // // Scoped to the executable fence on purpose: the surrounding prose cites // the jq form in order to explain why it is not used, and an assertion // over the whole file would fire on its own rationale. const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8'); // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content const fences = content.match(/```bash\r?\n[\s\S]*?```/g) || []; const statusFence = fences.find((f) => f.includes('gsd_run query verification.status')); assert.ok(statusFence, 'the status read must live in a bash fence'); assert.ok( statusFence.includes('--pick status'), 'the bare status must be picked by the query itself', ); assert.ok(!/\|\s*jq\b/.test(statusFence), 'the status-read fence must not pipe through jq'); }); test('the routing table carries a terminal arm for missing / unknown / stale', () => { const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8'); const gapsIdx = content.indexOf('| `gaps_found` |'); const fallbackIdx = content.indexOf('| anything else'); assert.ok(gapsIdx !== -1, 'the three verifier-status arms must remain'); assert.ok(fallbackIdx > gapsIdx, 'a terminal arm must follow the verifier-status arms'); const fallbackRow = content.slice(fallbackIdx, content.indexOf('\n', fallbackIdx)); for (const sentinel of ['missing', 'unknown', 'stale']) { assert.ok( fallbackRow.includes(sentinel), `the terminal arm must name the ${sentinel} sentinel the query can return`, ); } assert.ok( fallbackRow.includes('VERIFICATION_STATUS'), 'the terminal arm must set the display string consumed by the quick index row and banner', ); }); }); }); }