'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; msd-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, createTempGitProject } = require('./helpers.cjs'); const { runGit: seamRunGit, OUTCOME } = require('./helpers/process-seam.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { scanFencedBlocks } = require('../msd-core/bin/lib/markdown-sectionizer.cjs'); const { VERIFIER_STATUSES, VERIFICATION_ROUTING_TABLE, defaultPhaseCleanCommitTimesMs, resolveVerificationFile, resolveUatFile, readVerificationStatus, findStaleVerificationSummary, isPhaseComplete, computeCoveredDigest, sharedPlanningRoots, isSharedPlanningDoc, parseFingerprintVersion, parseFingerprintFileArgs, } = require('../msd-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(), `msd-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(), 'msd-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, '/msd-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, '/msd-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, '/msd-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, '/msd-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/msd-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: msd-verifier.md emitted statuses all have routing table entries', () => { const verifierPath = path.join(__dirname, '..', 'agents', 'msd-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 msd-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, `msd-verifier.md emits status '${s}' but VERIFICATION_ROUTING_TABLE has no entry for it. ` + 'Add a route or remove/rename the status in msd-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(), 'msd-651-nonexistent-' + Date.now()); const result = readVerificationStatus(nonexistent); assert.equal(result.status, 'missing', 'unreadable/nonexistent dir must return missing'); assert.equal(result.next_command, '/msd-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(), 'msd-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); // #4682: stale means covered source changed after the verifier ran — the // only remedy is re-running the verifier. execute-phase resumes at the // verification gates and re-runs it (its resume tree routes a stale // report to re-verification); /msd-verify-work never rewrote the report, // so routing there was an advice loop. assert.equal(result.next_command, '/msd-execute-phase 01'); assert.doesNotMatch(result.next_command, /verify-work/); assert.match(result.next_action, /verifier/i, 'the stale action must name the verifier re-run as the remedy'); } 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(), 'msd-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, '/msd-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(), 'msd-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, '/msd-execute-phase 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(), 'msd-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(), 'msd-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, '/msd-execute-phase 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(), 'msd-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(), 'msd-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, '/msd-execute-phase 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(), 'msd-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(), 'msd-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(), 'msd-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(), 'msd-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, '/msd-execute-phase 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(), 'msd-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, '/msd-execute-phase 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, '..', 'msd-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(), 'msd-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(), 'msd-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(), 'msd-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(), 'msd-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(), 'msd-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) originally // did not. `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. Since #4187 the two module-internal call sites pass `allowBare: // true` as well (see the #4187 describe below), so every reader of the report // set now agrees; the option's default remains `false` for callers that have // not opted in. 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', ); }); }); // ─── #4187: a bare VERIFICATION.md is a first-class report on the status surface ── // // `query verification.resolve-file` (cmdVerificationResolveFile), the phase-status // surface (commands.cts determinePhaseStatus) and both init verification_path // projectors all resolve a BARE `VERIFICATION.md` — but readVerificationStatus // (the reader behind `query verification.status`, isPhaseComplete, and the state/ // roadmap completion projections) called the same shared resolver WITHOUT // `allowBare`, so a phase whose only report was bare read as `missing` and was // told to re-run execute-phase even when the report said `status: passed`. // Two read-only verbs disagreed about the same directory at the same instant // (#4187). The fix opts the status reader — and findStaleVerificationSummary, // its internal legacy staleness check, whose only production caller is // readVerificationStatus — into the same `allowBare: true` the other four call // sites already pass. Tier order is unchanged: a dashed candidate (canonical or // not) still outranks the bare name, so only bare-ONLY directories change // behavior, from `missing` to the report's actual frontmatter status. describe('#4187: status surface recognizes a bare VERIFICATION.md', () => { test('#4187 regression: bare VERIFICATION.md with status: passed reads as passed, not missing', (t) => { const dir = mkPhaseDir('bare-passed', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'passed', 'a bare report is a report — the phase is verified'); assert.equal(result.next_command, '', 'passed must route to no next command'); assert.equal(result.staleCheckIndeterminate, undefined, 'the staleness check must run to completion'); }); test('#4187: bare report with status: human_needed routes to verify-work', (t) => { const dir = mkPhaseDir('bare-human', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'human_needed'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'human_needed'); assert.equal(result.next_command, '/msd-verify-work 99'); }); test('#4187: bare report with status: gaps_found routes to plan-phase --gaps', (t) => { const dir = mkPhaseDir('bare-gaps', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'gaps_found'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'gaps_found'); assert.equal(result.next_command, '/msd-plan-phase 99 --gaps'); }); test('#4187 negative space: no verification file at all still reads missing with the execute-phase recommendation', (t) => { const dir = mkPhaseDir('bare-none', '99-probe'); t.after(() => cleanup(path.dirname(dir))); const result = readVerificationStatus(dir); assert.equal(result.status, 'missing', 'a genuinely absent report must stay missing'); assert.equal(result.next_command, '/msd-execute-phase 99', 'the missing recommendation is correct here and must not change'); }); test('#4187 parity: bare + canonical dashed report — the dashed file wins on the status surface too', (t) => { const dir = mkPhaseDir('bare-vs-dashed', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'gaps_found'); writeVerificationMd(dir, '99-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'passed', 'a dashed file names its phase and must outrank the bare match'); }); test('#4187 parity: bare + cross-phase dashed stray — #3511 scoping falls through to the bare report', (t) => { const dir = mkPhaseDir('bare-vs-stray', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'passed'); writeVerificationMd(dir, '04-VERIFICATION.md', 'gaps_found'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'passed', 'the stray belongs to phase 04; the bare file is this phase\'s only own report'); }); test('#4187 parity: same-dir non-canonical dashed report only — unchanged behavior', (t) => { const dir = mkPhaseDir('dashed-noncanon', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, '99-CORRECTION-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'passed'); }); test('#4187: directory scoping — a bare report in a DIFFERENT directory does not leak into the queried one', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4187-otherdir-')); t.after(() => cleanup(baseDir)); const queried = path.join(baseDir, '99-probe'); const neighbor = path.join(baseDir, '98-other'); fs.mkdirSync(queried); fs.mkdirSync(neighbor); writeVerificationMd(neighbor, 'VERIFICATION.md', 'passed'); const result = readVerificationStatus(queried); assert.equal(result.status, 'missing', 'the neighbor directory\'s report must not answer for the queried one'); }); test('#4187: bare report with no frontmatter block resolves but reads missing', (t) => { const dir = mkPhaseDir('bare-no-fm', '99-probe'); t.after(() => cleanup(path.dirname(dir))); fs.writeFileSync(path.join(dir, 'VERIFICATION.md'), '# Verification\n'); const result = readVerificationStatus(dir); assert.equal(result.status, 'missing', 'a resolved file with no frontmatter status is the pre-existing missing path'); }); test('#4187 staleness: a bare report older than its summary reads stale (legacy mtime path)', (t) => { const dir = mkPhaseDir('bare-stale', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'passed'); setMtime(path.join(dir, 'VERIFICATION.md'), '2020-01-01T00:00:00Z'); // Root-style summary placement — mirrors the #3492 fixture above. const summaryPath = path.join(dir, '99-01-SUMMARY.md'); fs.writeFileSync(summaryPath, '# summary\n'); setMtime(summaryPath, '2021-01-01T00:00:00Z'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'stale', 'a bare report must be staleness-checked like a dashed one'); assert.equal(result.next_command, '/msd-execute-phase 99'); }); test('#4187 unit (findStaleVerificationSummary): staleness is computed against the bare report', (t) => { const dir = mkPhaseDir('bare-stale-unit', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'passed'); setMtime(path.join(dir, 'VERIFICATION.md'), '2020-01-01T00:00:00Z'); const summaryPath = path.join(dir, '99-01-SUMMARY.md'); fs.writeFileSync(summaryPath, '# summary\n'); setMtime(summaryPath, '2021-01-01T00:00:00Z'); const result = findStaleVerificationSummary(dir, fs, () => new Map()); assert.equal(result.determined, true); assert.equal(result.stale, true); assert.equal(result.verificationFile, 'VERIFICATION.md', 'the staleness seam must see the bare report'); }); }); // ─── #4142: bracket convention reaches the legacy staleness seam ──────────── // // readVerificationStatus resolves the report once to read its frontmatter and // findStaleVerificationSummary resolves it again for the legacy mtime check. // Both resolutions must receive the same convention or a bracket directory can // read status from its own report, then compute staleness from a cross-phase // stray in that same directory. describe('#4142: opts.convention threads through findStaleVerificationSummary', () => { test('a bracket phase checks staleness against its own report, not a newer cross-phase stray', (t) => { const dir = mkPhaseDir('bracket-stale-thread', 'MSD.02-03-three'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, '03-VERIFICATION.md', 'passed'); writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); setMtime(path.join(dir, '03-VERIFICATION.md'), '2020-01-01T00:00:00Z'); setMtime(path.join(dir, '01-VERIFICATION.md'), '2022-01-01T00:00:00Z'); const summaryPath = path.join(dir, '03-01-SUMMARY.md'); fs.writeFileSync(summaryPath, '# summary\n'); setMtime(summaryPath, '2021-01-01T00:00:00Z'); const result = readVerificationStatus(dir, { convention: 'bracket', phaseCleanCommitTimesMs: () => new Map(), }); assert.equal( result.status, 'stale', 'opts.convention must reach the legacy staleness seam so phase 03 is compared to 03-VERIFICATION.md', ); assert.equal(result.next_command, '/msd-execute-phase'); }); }); // ─── #4142: phase complete must use the convention-aware verdict ─────────── // // cmdPhaseComplete has an advisory VERIFICATION pre-scan and a separate // readVerificationStatus completion gate. Exercise the real CLI verdict so a // cross-phase report cannot satisfy the gate merely by sitting in the bracket // phase's directory. describe('#4142: phase complete verdict scopes bracket verification reports', () => { const { runMsdTools } = require('./helpers.cjs'); test('a passed cross-phase stray cannot complete a bracket phase', (t) => { const projectDir = createTempGitProject('msd-4142-phase-complete-'); t.after(() => cleanup(projectDir)); fs.writeFileSync( path.join(projectDir, '.planning', 'config.json'), JSON.stringify({ phase_id_convention: 'bracket' }, null, 2), ); const phaseDirName = 'MSD.02-03-three'; const phaseDir = path.join(projectDir, '.planning', 'phases', phaseDirName); fs.mkdirSync(phaseDir, { recursive: true }); writeVerificationMd(phaseDir, '01-VERIFICATION.md', 'passed'); const result = runMsdTools( ['--json-errors', 'phase', 'complete', phaseDirName], projectDir, ); assert.equal( result.success, false, 'phase complete must reject another phase\'s passed verification report', ); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_verification_incomplete'); }); }); // ─── #4187 CLI parity: the two query verbs must agree on the same directory ─── // // The issue's repro shape: run `query verification.resolve-file` and // `query verification.status` back to back against ONE fixture directory and // assert they never disagree about whether a report exists (and, when one does, // about WHICH file is the report — enforced by giving the candidates distinct // frontmatter statuses and asserting the routed status matches the expected // winner). describe('#4187 CLI parity: verification.resolve-file and verification.status agree', () => { const { runMsdTools } = require('./helpers.cjs'); function runBothVerbs(dir) { const resolveRes = runMsdTools(['query', 'verification.resolve-file', dir, '--raw'], dir); const statusRes = runMsdTools(['query', 'verification.status', dir, '--raw'], dir); assert.equal(resolveRes.success, true, `resolve-file failed: ${resolveRes.error}`); assert.equal(statusRes.success, true, `status failed: ${statusRes.error}`); return { resolvedPath: resolveRes.output.trimEnd(), status: JSON.parse(statusRes.output).status, nextCommand: JSON.parse(statusRes.output).next_command }; } test('bare report: resolve-file finds it and status reads passed (the #4187 repro)', (t) => { const dir = mkPhaseDir('cli-bare', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'passed'); const { resolvedPath, status, nextCommand } = runBothVerbs(dir); assert.equal(resolvedPath, path.join(dir, 'VERIFICATION.md')); assert.equal(status, 'passed', 'status must not report missing for a file resolve-file resolves'); assert.equal(nextCommand, ''); }); test('no report: resolve-file is empty and status is missing (agreement in the other direction)', (t) => { const dir = mkPhaseDir('cli-none', '99-probe'); t.after(() => cleanup(path.dirname(dir))); const { resolvedPath, status } = runBothVerbs(dir); assert.equal(resolvedPath, '', 'resolve-file must report no file'); assert.equal(status, 'missing'); }); test('bare + canonical dashed: both verbs pick the dashed file', (t) => { const dir = mkPhaseDir('cli-both', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'gaps_found'); writeVerificationMd(dir, '99-VERIFICATION.md', 'passed'); const { resolvedPath, status } = runBothVerbs(dir); assert.equal(resolvedPath, path.join(dir, '99-VERIFICATION.md')); assert.equal(status, 'passed', 'status must read the same winner resolve-file picked'); }); test('bare + cross-phase stray: both verbs pick the bare file', (t) => { const dir = mkPhaseDir('cli-stray', '99-probe'); t.after(() => cleanup(path.dirname(dir))); writeVerificationMd(dir, 'VERIFICATION.md', 'passed'); writeVerificationMd(dir, '04-VERIFICATION.md', 'gaps_found'); const { resolvedPath, status } = runBothVerbs(dir); assert.equal(resolvedPath, path.join(dir, 'VERIFICATION.md')); assert.equal(status, 'passed', 'status must read the same winner resolve-file picked'); }); }); // ─── #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(), 'msd-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(), 'msd-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', ); }); }); // ─── #4155: covered-input fingerprint ───────────────────────────────────────── // // A VERIFICATION.md that declares `covered_files` + `covered_digest` in its // frontmatter is checked by RECOMPUTING that digest over current file content // — this REPLACES the legacy SUMMARY-mtime check for that report (not merely // supplements it). A report with no fingerprint metadata keeps the exact // legacy mtime behavior (already covered above). describe('#4155: computeCoveredDigest — direct unit coverage', () => { test('same covered set, different array order → identical digest (order-independent)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-order-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(root, 'a.txt'), 'A'); fs.writeFileSync(path.join(root, 'b.txt'), 'B'); const d1 = computeCoveredDigest(root, ['a.txt', 'b.txt']); const d2 = computeCoveredDigest(root, ['b.txt', 'a.txt']); assert.equal(d1, d2); // The version prefix is pinned by the #4623 block below; this test is about order-independence. assert.match(d1, /^v\d+:sha256:[0-9a-f]{64}$/); }); test('a "./"-prefixed path and its bare equivalent → identical digest (canonicalized, not double-counted)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-dotslash-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(root, 'impl.txt'), 'content'); const bare = computeCoveredDigest(root, ['impl.txt']); const dotSlash = computeCoveredDigest(root, ['./impl.txt']); assert.equal(dotSlash, bare, '"./impl.txt" must canonicalize to the same key as "impl.txt"'); // Both spellings together must not double-hash the same file into the digest. const combined = computeCoveredDigest(root, ['impl.txt', './impl.txt']); assert.equal(combined, bare); }); test('an internal ".." segment disguising an escape (not just a leading one) → null (fail closed)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-internal-dotdot-')); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, 'a')); fs.writeFileSync(path.join(path.dirname(root), 'outside.txt'), 'secret'); // 'a/../../outside.txt' does not start with '../' as written, but // normalizes to '../outside.txt' — an escape a purely-prefix check misses. assert.equal(computeCoveredDigest(root, ['a/../../outside.txt']), null); }); test('a covered file whose content changes → digest changes', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-changed-')); t.after(() => cleanup(root)); const target = path.join(root, 'impl.txt'); fs.writeFileSync(target, 'before'); const before = computeCoveredDigest(root, ['impl.txt']); fs.writeFileSync(target, 'after'); const after = computeCoveredDigest(root, ['impl.txt']); assert.notEqual(before, after); }); test('a covered file that is missing → null (fail closed)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-missing-')); t.after(() => cleanup(root)); assert.equal(computeCoveredDigest(root, ['does-not-exist.txt']), null); }); test('a covered file that is unreadable (directory, not a regular file) → null (fail closed)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-unreadable-')); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, 'a-directory')); assert.equal(computeCoveredDigest(root, ['a-directory']), null); }); test('a covered path that escapes the project root via ".." → null (fail closed)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-escape-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(path.dirname(root), 'outside.txt'), 'secret'); assert.equal(computeCoveredDigest(root, ['../outside.txt']), null); }); test('an absolute covered path → null (fail closed)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-absolute-')); t.after(() => cleanup(root)); const absolute = path.join(root, 'impl.txt'); fs.writeFileSync(absolute, 'x'); assert.equal(computeCoveredDigest(root, [absolute]), null); }); test('an empty covered-files array → null', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-empty-')); t.after(() => cleanup(root)); assert.equal(computeCoveredDigest(root, []), null); }); test('an in-root symlink whose TARGET escapes the project root → null (fail closed, not the target\'s content)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-symlink-')); t.after(() => cleanup(root)); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-symlink-outside-')); t.after(() => cleanup(outside)); const outsideFile = path.join(outside, 'secret.txt'); fs.writeFileSync(outsideFile, 'not covered by this project'); const linkPath = path.join(root, 'impl.txt'); fs.symlinkSync(outsideFile, linkPath); assert.equal(computeCoveredDigest(root, ['impl.txt']), null); }); }); describe('#4155: readVerificationStatus — fingerprint supersedes legacy mtime staleness', () => { test('a covered implementation file OUTSIDE .planning/, read through a .planning/-confined opts.fs (src/planning-inspect.cts\'s exact seam) → status stays passed, not stale', () => { // #4155 review finding (blocking): src/planning-inspect.cts:1253 injects // containmentEnforcingVerificationFs(paths.planning) — confined to // `.planning/` — as `opts.fs`. Before the fix, computeCoveredDigest routed // every per-file read through that SAME injected fsImpl, so any covered // implementation file (which the issue mandates live under `src/`, outside // `.planning/`) made the confinement wrapper throw, which computeCoveredDigest // caught and turned into `null`, which readVerificationStatus routed to // `stale` — permanently, regardless of whether anything actually changed. // This reproduces that exact call shape with a real nested .planning/ // project (findProjectRoot must resolve past the phase dir to the real // root) and a confinement fs scoped ONLY to .planning/, mirroring // planning-inspect.cts's containmentEnforcingVerificationFs byte-for-byte. const projectDir = createTempGitProject(); try { const planningRoot = path.join(projectDir, '.planning'); const phaseDir = path.join(planningRoot, 'phases', '01-example'); fs.mkdirSync(phaseDir, { recursive: true }); const implPath = path.join(projectDir, 'src', 'impl.ts'); fs.mkdirSync(path.dirname(implPath), { recursive: true }); fs.writeFileSync(implPath, 'export const x = 1;\n'); const digest = computeCoveredDigest(projectDir, ['src/impl.ts']); fs.writeFileSync( path.join(phaseDir, '01-VERIFICATION.md'), `---\nstatus: passed\ncovered_files:\n - src/impl.ts\ncovered_digest: "${digest}"\n---\n`, ); // A naive lexical-prefix check (no realpath resolution) is a stricter // stand-in for src/planning-inspect.cts's real containmentEnforcingVerificationFs // here: it throws on strictly MORE paths than the real one (it can't // tell a legitimate in-root path from a symlink, so it rejects both), // which makes this test fail harder, not weaker, if the fix regresses. function assertContained(target) { if (!path.resolve(target).startsWith(planningRoot + path.sep)) { throw new Error(`planning-inspect: path escapes planning root: ${target}`); } } const containmentEnforcingVerificationFs = { readdirSync: (dir) => { assertContained(dir); return fs.readdirSync(dir); }, readFileSync: (filePath, encoding) => { assertContained(filePath); return fs.readFileSync(filePath, encoding); }, statSync: (filePath) => { assertContained(filePath); return fs.statSync(filePath); }, }; const result = readVerificationStatus(phaseDir, { fs: containmentEnforcingVerificationFs, phaseCleanCommitTimesMs: () => new Map(), }); assert.equal(result.status, 'passed'); } finally { cleanup(projectDir); } }); test('unchanged covered inputs → status stays passed even though a SUMMARY is newer (legacy mtime check bypassed)', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-unchanged-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-foo'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'impl.txt'), 'implementation content'); const summaryPath = path.join(dir, '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, '# Summary'); // The SUMMARY is a covered artifact too — isolates this test to the mtime // vs. content-digest distinction, not the #4155 completeness check below. const digest = computeCoveredDigest(dir, ['impl.txt', '01-01-SUMMARY.md']); fs.writeFileSync( path.join(dir, '01-VERIFICATION.md'), `---\nstatus: passed\ncovered_files:\n - impl.txt\n - 01-01-SUMMARY.md\ncovered_digest: "${digest}"\n---\n`, ); // SUMMARY newer than VERIFICATION — the LEGACY check would call this // stale. The fingerprint check must be the one that actually runs. setMtime(path.join(dir, '01-VERIFICATION.md'), '2026-01-01T00:00:00.000Z'); setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'passed'); }); test('a plan or summary added to the phase dir after verification, never declared in covered_files → stale (#4155 completeness check)', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-uncovered-artifact-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-foo'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'impl.txt'), 'content'); const digest = computeCoveredDigest(dir, ['impl.txt']); fs.writeFileSync( path.join(dir, '01-VERIFICATION.md'), `---\nstatus: passed\ncovered_files:\n - impl.txt\ncovered_digest: "${digest}"\n---\n`, ); // A SUMMARY appears after verification — never declared, so the recomputed // digest over the ORIGINAL covered set still matches. Only the live // directory re-scan can catch this. fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), '# Summary\n'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'stale'); }); test('an unreadable nested plans/ dir fails CLOSED to stale, not open to passed (#4155 review finding: allCurrentArtifactsCovered must branch on scanPhasePlans scope, not a try/catch it never throws into)', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-scan-unreadable-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-foo'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'impl.txt'), 'content'); const digest = computeCoveredDigest(dir, ['impl.txt']); fs.writeFileSync( path.join(dir, '01-VERIFICATION.md'), `---\nstatus: passed\ncovered_files:\n - impl.txt\ncovered_digest: "${digest}"\n---\n`, ); const nestedDir = path.join(dir, 'plans'); fs.mkdirSync(nestedDir); fs.writeFileSync(path.join(nestedDir, '01-02-PLAN.md'), '# Nested plan, never declared\n'); // fs.chmodSync(nestedDir, 0o000) is NOT used: this suite may run as root // (CI/Docker), where mode bits are bypassed entirely, making the test // pass with zero real coverage. A monkeypatch is CONTRIBUTING.md's // documented fault-injection convention for exactly this reason (mirrors // tests/broken-windows.test.cjs's writeLedgerAtomic pre-image test). The // compiled src/plan-scan.cjs calls `node_fs_1.readdirSync(nestedDir)` — a // property read on the SAME node:fs module object `fs` here resolves to // (module caching), so patching that property is visible to it. const originalReaddirSync = fs.readdirSync; fs.readdirSync = (target, ...rest) => { if (path.resolve(target) === path.resolve(nestedDir)) { throw Object.assign(new Error('EACCES: permission denied (simulated)'), { code: 'EACCES' }); } return originalReaddirSync(target, ...rest); }; try { // Guard: the digest alone must NOT already be stale — isolates this // test to the scan-failure branch, not a digest mismatch. const unpatchedScan = originalReaddirSync(dir); assert.ok(unpatchedScan.includes('plans'), 'guard: nested plans/ dir must exist on disk'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal( result.status, 'stale', 'an unreadable plans/ dir must fail CLOSED — its invisible contents (an undeclared plan) can never be proven covered', ); } finally { fs.readdirSync = originalReaddirSync; } }); test('a covered file that changed after verification → status is stale', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-stale-changed-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-foo'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'impl.txt'), 'original content'); const digest = computeCoveredDigest(dir, ['impl.txt']); fs.writeFileSync( path.join(dir, '01-VERIFICATION.md'), `---\nstatus: passed\ncovered_files:\n - impl.txt\ncovered_digest: "${digest}"\n---\n`, ); // Evidence drifts after verification — no SUMMARY touched at all, so the // legacy mtime check would see nothing stale. fs.writeFileSync(path.join(dir, 'impl.txt'), 'drifted content'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'stale'); assert.equal(result.next_command, '/msd-execute-phase 01'); }); // Ponytail #4155 review finding: "disappeared" and "escapes confinement" // integration tests previously here re-proved computeCoveredDigest → null // through the identical `recomputed !== coveredDigestVal` stale branch the // "changed" test above already wires — the null-producing mechanisms // themselves are unit-tested directly in the computeCoveredDigest describe // block ("a covered file that is missing", "a covered path that escapes // the project root via .."). // Ponytail #4155 review finding: these three were near-identical // fixture-copies of the same `hasWellFormedFingerprint === false` branch — // an incomplete/malformed `covered_files`+`covered_digest` pair fails // closed to `stale` rather than silently downgrading to the legacy check. for (const [name, frontmatter] of [ [ 'covered_files present but covered_digest missing (incomplete pair)', '---\nstatus: passed\ncovered_files:\n - impl.txt\n---\n', ], [ 'covered_digest present but covered_files missing (incomplete pair)', '---\nstatus: passed\ncovered_digest: "v1:sha256:deadbeef"\n---\n', ], [ 'an empty covered_files array with covered_digest present', '---\nstatus: passed\ncovered_files: []\ncovered_digest: "v1:sha256:deadbeef"\n---\n', ], ]) { test(`${name} → stale (fail closed, not a silent legacy downgrade)`, (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-malformed-fingerprint-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-foo'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), frontmatter); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'stale'); }); } test('a legacy report with no fingerprint metadata still uses the mtime check', (t) => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4155-legacy-')); t.after(() => cleanup(baseDir)); const dir = path.join(baseDir, '01-foo'); 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'); setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(result.status, 'stale', 'unchanged: legacy mtime staleness must still fire with no covered_digest'); }); }); describe('#4155: verification.fingerprint CLI', () => { const { runMsdTools, createTempGitProject } = require('./helpers.cjs'); test('emits a covered_digest matching the direct computeCoveredDigest call, sorted covered_files', () => { 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 = runMsdTools( [ 'verification', 'fingerprint', phaseDir, '.planning/phases/01-example/01-01-SUMMARY.md', '.planning/phases/01-example/01-01-PLAN.md', ], projectDir, ); assert.equal(res.success, true, `expected success, got: ${res.output}${res.error}`); const parsed = JSON.parse(res.output); assert.deepEqual(parsed.covered_files, [ '.planning/phases/01-example/01-01-PLAN.md', '.planning/phases/01-example/01-01-SUMMARY.md', ]); const expected = computeCoveredDigest(projectDir, [ '.planning/phases/01-example/01-01-PLAN.md', '.planning/phases/01-example/01-01-SUMMARY.md', ]); assert.equal(parsed.covered_digest, expected); } finally { cleanup(projectDir); } }); test('a missing covered file fails the whole command (fail closed, no partial fingerprint)', () => { const projectDir = createTempGitProject(); try { const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example'); fs.mkdirSync(phaseDir, { recursive: true }); const res = runMsdTools( ['verification', 'fingerprint', phaseDir, 'does-not-exist.md'], projectDir, ); assert.equal(res.success, false); } finally { cleanup(projectDir); } }); test('end-to-end through a real nested .planning/ project: a project-root-relative src/ file is covered, resolved, and hashed correctly', () => { // #4155 review finding: unit fixtures elsewhere in this file put phaseDir // directly under an ownerless tmpdir, so findProjectRoot(phaseDir) falls // back to phaseDir itself and never exercises real multi-level // resolution. This test uses a genuine `.planning/phases/NN-x/` tree // under a real project root, and covers an implementation file OUTSIDE // `.planning/` entirely — the exact shape a live verifier agent produces. 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.mkdirSync(path.join(projectDir, 'src')); fs.writeFileSync(path.join(projectDir, 'src', 'thing.cts'), 'export const x = 1;\n'); const coveredFiles = [ '.planning/phases/01-example/01-01-PLAN.md', '.planning/phases/01-example/01-01-SUMMARY.md', 'src/thing.cts', ]; const fpRes = runMsdTools(['verification', 'fingerprint', phaseDir, ...coveredFiles], projectDir); assert.equal(fpRes.success, true, `expected success, got: ${fpRes.output}${fpRes.error}`); const { covered_files: sortedCovered, covered_digest: digest } = JSON.parse(fpRes.output); fs.writeFileSync( path.join(phaseDir, '01-VERIFICATION.md'), `---\nstatus: passed\ncovered_files:\n${sortedCovered.map((f) => ` - ${f}`).join('\n')}\ncovered_digest: "${digest}"\n---\n`, ); const passing = readVerificationStatus(phaseDir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(passing.status, 'passed'); // Now edit the implementation file OUTSIDE .planning/ — must go stale. fs.writeFileSync(path.join(projectDir, 'src', 'thing.cts'), 'export const x = 2;\n'); const afterEdit = readVerificationStatus(phaseDir, { phaseCleanCommitTimesMs: () => new Map() }); assert.equal(afterEdit.status, 'stale'); } finally { cleanup(projectDir); } }); }); // ─── #4623: shared planning documents + fingerprint argv ───────────────────── // // Two defects, one issue. (1) `computeCoveredDigest` hashed the whole bytes of // repo-wide planning documents (`.planning/ROADMAP.md`, `REQUIREMENTS.md`, …) // into a phase's digest, so any phase's ordinary bookkeeping — including the // closing phase's OWN checkbox flip — read as drift for every phase that had // declared them. Fingerprint v2 excludes those documents by construction, and // a stored v1 digest keeps v1 semantics so an upgrade does not stale every // already-verified phase. (2) `verification.fingerprint` took a raw positional // slice: every `--files` form failed closed as "a covered file is missing", // and an omitted phase dir silently hashed the wrong set at exit 0. const NO_GIT_TIMES = { phaseCleanCommitTimesMs: () => new Map() }; function makePhase4623(projectDir, name) { const phaseDir = path.join(projectDir, '.planning', 'phases', name); fs.mkdirSync(phaseDir, { recursive: true }); const num = name.split('-')[0]; fs.writeFileSync(path.join(phaseDir, `${num}-01-PLAN.md`), `# Plan ${name}\n`); fs.writeFileSync(path.join(phaseDir, `${num}-01-SUMMARY.md`), `# Summary ${name}\n`); return { phaseDir, num, ownFiles: [ `.planning/phases/${name}/${num}-01-PLAN.md`, `.planning/phases/${name}/${num}-01-SUMMARY.md`, ], }; } function writeReport4623(phase, coveredFiles, digest) { const sorted = [...new Set(coveredFiles)].sort(); fs.writeFileSync( path.join(phase.phaseDir, `${phase.num}-VERIFICATION.md`), `---\nstatus: passed\ncovered_files:\n${sorted.map((f) => ` - ${f}`).join('\n')}\ncovered_digest: "${digest}"\n---\n`, ); } function writeSharedDocs4623(projectDir, { roadmapDone = false, reqDone = false } = {}) { fs.writeFileSync( path.join(projectDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n- [${roadmapDone ? 'x' : ' '}] **Phase 1: Alpha**\n- [ ] **Phase 2: Beta**\n`, ); fs.writeFileSync( path.join(projectDir, '.planning', 'REQUIREMENTS.md'), `# Requirements\n\n- [${reqDone ? 'x' : ' '}] **REQ-01**: The thing works\n\n| REQ-01 | Phase 1 | ${reqDone ? 'Complete' : 'Pending'} |\n`, ); } const SHARED_DOCS_4623 = ['.planning/ROADMAP.md', '.planning/REQUIREMENTS.md']; describe('#4623: isSharedPlanningDoc — a repo-wide planning document is a DIRECT child of .planning/', () => { test('top-level planning documents are shared, whatever their name', () => { for (const rel of [ '.planning/ROADMAP.md', '.planning/REQUIREMENTS.md', '.planning/STATE.md', '.planning/PROJECT.md', '.planning/MILESTONES.md', '.planning/config.json', ]) { assert.equal(isSharedPlanningDoc(rel), true, rel); } }); test('phase artifacts, nested planning files, implementation files, and a same-named root file are not', () => { for (const rel of [ '.planning/phases/01-example/01-01-PLAN.md', '.planning/phases/01-example/01-VERIFICATION.md', '.planning/research/notes.md', '.planning/milestones/v1.0-ROADMAP.md', 'src/thing.cts', 'ROADMAP.md', '.planning', '.planning/ROADMAP.md/', '', ]) { assert.equal(isSharedPlanningDoc(rel), false, JSON.stringify(rel)); } }); test('extra planning roots make their direct children shared; sharedPlanningRoots derives them from the phase dir', (t) => { const roots = ['.planning', '.planning/workstreams/w']; assert.equal(isSharedPlanningDoc('.planning/workstreams/w/ROADMAP.md', roots), true); assert.equal(isSharedPlanningDoc('.planning/workstreams/w/phases/01-a/01-01-PLAN.md', roots), false); assert.equal(isSharedPlanningDoc('.planning/workstreams/w/ROADMAP.md'), false, 'unknown root without the phase dir'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-roots-')); t.after(() => cleanup(root)); assert.deepEqual(sharedPlanningRoots(root), ['.planning']); assert.deepEqual(sharedPlanningRoots(root, path.join(root, '.planning', 'phases', '01-a')), ['.planning']); assert.deepEqual(sharedPlanningRoots(root, path.join(root, '.planning', 'proj', 'phases', '01-a')), ['.planning', '.planning/proj']); assert.deepEqual(sharedPlanningRoots(root, path.join(root, '.planning', 'proj', 'workstreams', 'w', 'phases', '01-a')), ['.planning', '.planning/proj/workstreams/w']); // A phase dir outside the project root contributes nothing. assert.deepEqual(sharedPlanningRoots(root, path.join(os.tmpdir(), 'elsewhere', 'phases', '01-a')), ['.planning']); assert.deepEqual(sharedPlanningRoots(root, root), ['.planning']); // Structural: the parent must be `phases/` and the root must sit inside .planning/ — an // arbitrary accepted directory must never nominate its grandparent as a planning root. assert.deepEqual(sharedPlanningRoots(root, path.join(root, 'src', 'phases', '01-fake')), ['.planning']); assert.deepEqual(sharedPlanningRoots(root, path.join(root, '.planning', 'proj', 'notphases', '01-a')), ['.planning']); assert.deepEqual(sharedPlanningRoots(root, path.join(root, '.planning', 'phases')), ['.planning']); assert.deepEqual(sharedPlanningRoots(root, path.join(root, '.planning')), ['.planning']); }); }); describe('#4623: parseFingerprintVersion', () => { test('reads the version prefix of a well-formed digest', () => { assert.equal(parseFingerprintVersion('v1:sha256:' + 'a'.repeat(64)), 1); assert.equal(parseFingerprintVersion('v2:sha256:' + 'a'.repeat(64)), 2); }); test('an unknown, malformed, or absent version → null (fail closed)', () => { assert.equal(parseFingerprintVersion('v9:sha256:' + 'a'.repeat(64)), null); assert.equal(parseFingerprintVersion('sha256:' + 'a'.repeat(64)), null); assert.equal(parseFingerprintVersion('v2:md5:' + 'a'.repeat(32)), null); assert.equal(parseFingerprintVersion(''), null); }); }); describe('#4623: parseFingerprintFileArgs — every --files form the issue tried, plus the documented bare form', () => { test('bare positionals pass through unchanged, commas included (AC4)', () => { assert.deepEqual(parseFingerprintFileArgs(['a.rb', 'b.rb']), { files: ['a.rb', 'b.rb'] }); // Only a --files VALUE is comma-split: the documented form keeps its bytes. assert.deepEqual(parseFingerprintFileArgs(['a,b']), { files: ['a,b'] }); assert.deepEqual(parseFingerprintFileArgs([]), { files: [] }); }); test('--files (AC2)', () => { assert.deepEqual(parseFingerprintFileArgs(['--files', 'fastlane/Fastfile']), { files: ['fastlane/Fastfile'] }); }); test('--files "a,b" and --files=a,b split on commas, trimming and dropping empties (AC3)', () => { assert.deepEqual(parseFingerprintFileArgs(['--files', 'a.rb,b.rb']), { files: ['a.rb', 'b.rb'] }); assert.deepEqual(parseFingerprintFileArgs(['--files', ' a.rb , b.rb ,']), { files: ['a.rb', 'b.rb'] }); assert.deepEqual(parseFingerprintFileArgs(['--files=a.rb,b.rb']), { files: ['a.rb', 'b.rb'] }); }); test('--files a --files b collects every occurrence (AC3)', () => { assert.deepEqual(parseFingerprintFileArgs(['--files', 'a.rb', '--files', 'b.rb']), { files: ['a.rb', 'b.rb'] }); }); test('forms mix freely, in order', () => { assert.deepEqual(parseFingerprintFileArgs(['x', '--files', 'a,b', 'y', '--files=c']), { files: ['x', 'a', 'b', 'y', 'c'], }); }); test('an empty --files value (--files=, --files ",", --files "") is a usage error, never a silent no-op', () => { for (const tokens of [['--files='], ['--files', ','], ['--files', ''], ['--files', ' , ']]) { const parsed = parseFingerprintFileArgs(tokens); assert.ok('error' in parsed, JSON.stringify(tokens)); assert.match(parsed.error, /--files requires at least one path/); } }); test('--files with no value, or followed by another flag, is a usage error', () => { for (const tokens of [['--files'], ['a', '--files'], ['--files', '--files', 'a']]) { const parsed = parseFingerprintFileArgs(tokens); assert.ok('error' in parsed, JSON.stringify(tokens)); assert.match(parsed.error, /--files requires a value/); } }); test('any other --flag is an explicit usage error naming the flag, never a covered path', () => { const parsed = parseFingerprintFileArgs(['a.rb', '--file', 'b.rb']); assert.ok('error' in parsed); assert.match(parsed.error, /unknown flag --file\b/); assert.doesNotMatch(parsed.error, /missing, unreadable/); }); }); describe('#4623: computeCoveredDigest v2 — shared planning documents do not enter the digest', () => { test('defaults to v2 and names the version in the digest', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-version-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(root, 'impl.txt'), 'x'); assert.match(computeCoveredDigest(root, ['impl.txt']), /^v2:sha256:[0-9a-f]{64}$/); assert.match(computeCoveredDigest(root, ['impl.txt'], 1), /^v1:sha256:[0-9a-f]{64}$/); assert.notEqual(computeCoveredDigest(root, ['impl.txt']), computeCoveredDigest(root, ['impl.txt'], 1)); }); test('an unknown version → null (fail closed, never a digest under guessed semantics)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-unknown-version-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(root, 'impl.txt'), 'x'); assert.equal(computeCoveredDigest(root, ['impl.txt'], 9), null); assert.equal(computeCoveredDigest(root, ['impl.txt'], 0), null); }); test('a byte change to .planning/ROADMAP.md or REQUIREMENTS.md leaves the v2 digest unchanged; a phase artifact or implementation change still moves it', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-shared-')); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(root, 'src')); writeSharedDocs4623(root); fs.writeFileSync(path.join(root, '.planning', 'phases', '01-a', '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(root, 'src', 'thing.cts'), 'export const x = 1;\n'); const covered = [...SHARED_DOCS_4623, '.planning/phases/01-a/01-01-PLAN.md', 'src/thing.cts']; const before = computeCoveredDigest(root, covered); writeSharedDocs4623(root, { roadmapDone: true, reqDone: true }); assert.equal(computeCoveredDigest(root, covered), before, 'shared-doc bookkeeping must not move a v2 digest'); fs.writeFileSync(path.join(root, '.planning', 'phases', '01-a', '01-01-PLAN.md'), '# Plan (edited)\n'); const afterPlan = computeCoveredDigest(root, covered); assert.notEqual(afterPlan, before, 'a phase artifact change must still move it'); fs.writeFileSync(path.join(root, 'src', 'thing.cts'), 'export const x = 2;\n'); assert.notEqual(computeCoveredDigest(root, covered), afterPlan, 'an implementation change must still move it'); }); test('a nested planning file (.planning/research/…) is NOT shared and still moves the digest', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-nested-')); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, '.planning', 'research'), { recursive: true }); const note = path.join(root, '.planning', 'research', 'notes.md'); fs.writeFileSync(note, 'v1'); const before = computeCoveredDigest(root, ['.planning/research/notes.md']); fs.writeFileSync(note, 'v2'); assert.notEqual(computeCoveredDigest(root, ['.planning/research/notes.md']), before); }); test('a declared shared document is validated like any other path — present: inert; missing, a directory, or an escaping symlink: null (fail closed, as v1)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-shared-validated-')); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, '.planning', 'phases'), { recursive: true }); fs.writeFileSync(path.join(root, 'impl.txt'), 'x'); const withoutDecl = computeCoveredDigest(root, ['impl.txt']); // Missing → null, exactly as v1. assert.equal(computeCoveredDigest(root, ['impl.txt', '.planning/ROADMAP.md']), null); fs.writeFileSync(path.join(root, '.planning', 'ROADMAP.md'), '# Roadmap\n'); // Present → contributes nothing: same digest as if undeclared. assert.equal(computeCoveredDigest(root, ['impl.txt', '.planning/ROADMAP.md']), withoutDecl); // A directory directly under the root is not a document → null, as v1. assert.equal(computeCoveredDigest(root, ['impl.txt', '.planning/phases']), null); // An in-root symlink whose target escapes → null, as v1 (nothing is read either way, // but the declaration is still an escape and still invalidates the set). const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-shared-outside-')); t.after(() => cleanup(outside)); fs.writeFileSync(path.join(outside, 'secret.txt'), 'not this project'); fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(root, '.planning', 'ESCAPE.md')); assert.equal(computeCoveredDigest(root, ['impl.txt', '.planning/ESCAPE.md']), null); }); test('a declaration made only of shared planning documents hashes nothing → null (fail closed, like an empty one)', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-all-shared-')); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, '.planning')); writeSharedDocs4623(root); assert.equal(computeCoveredDigest(root, SHARED_DOCS_4623), null); // v1 still hashes them. assert.match(computeCoveredDigest(root, SHARED_DOCS_4623, 1), /^v1:/); }); test('the phase\'s own planning root is shared too: a workstream-scoped ROADMAP.md is inert, a research note beside it is not', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-workstream-')); t.after(() => cleanup(root)); const wsRoot = path.join(root, '.planning', 'workstreams', 'payments'); const phaseDir = path.join(wsRoot, 'phases', '01-a'); fs.mkdirSync(phaseDir, { recursive: true }); fs.mkdirSync(path.join(root, '.planning', 'research'), { recursive: true }); fs.writeFileSync(path.join(wsRoot, 'ROADMAP.md'), '- [ ] Phase 1\n'); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(root, '.planning', 'research', 'notes.md'), 'v1'); const covered = [ '.planning/workstreams/payments/ROADMAP.md', '.planning/workstreams/payments/phases/01-a/01-01-PLAN.md', '.planning/research/notes.md', ]; assert.deepEqual(sharedPlanningRoots(root, phaseDir), ['.planning', '.planning/workstreams/payments']); const before = computeCoveredDigest(root, covered, 2, { phaseDir }); fs.writeFileSync(path.join(wsRoot, 'ROADMAP.md'), '- [x] Phase 1\n'); assert.equal(computeCoveredDigest(root, covered, 2, { phaseDir }), before, 'the workstream roadmap is this phase\'s shared doc'); // Without the phase dir the same path is NOT recognised (lexically it could be .planning//…). assert.notEqual(computeCoveredDigest(root, covered, 2), before); fs.writeFileSync(path.join(root, '.planning', 'research', 'notes.md'), 'v2'); assert.notEqual(computeCoveredDigest(root, covered, 2, { phaseDir }), before, 'a nested research note is evidence'); }); test('a shared-looking path that escapes the root still fails the whole set', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-escape-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(root, 'impl.txt'), 'x'); assert.equal(computeCoveredDigest(root, ['impl.txt', '../.planning/ROADMAP.md']), null); }); test('v1 semantics are preserved on request: a shared-doc change still moves a v1 digest', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4623-v1-')); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, '.planning')); fs.writeFileSync(path.join(root, 'impl.txt'), 'x'); writeSharedDocs4623(root); const before = computeCoveredDigest(root, ['impl.txt', ...SHARED_DOCS_4623], 1); writeSharedDocs4623(root, { roadmapDone: true }); assert.notEqual(computeCoveredDigest(root, ['impl.txt', ...SHARED_DOCS_4623], 1), before); }); }); describe('#4623: readVerificationStatus — shared planning documents no longer stale a phase', () => { const { runMsdTools } = require('./helpers.cjs'); function fingerprintViaCli(projectDir, phase, coveredFiles) { const res = runMsdTools(['verification', 'fingerprint', phase.phaseDir, ...coveredFiles], projectDir); assert.equal(res.success, true, `expected success, got: ${res.output}${res.error}`); return JSON.parse(res.output).covered_digest; } test('AC1 cross-phase: completing phase A (roadmap + requirement bookkeeping) leaves phase B passed; B\'s own artifact change still stales B alone', (t) => { const projectDir = createTempGitProject(); t.after(() => cleanup(projectDir)); writeSharedDocs4623(projectDir); const a = makePhase4623(projectDir, '01-alpha'); const b = makePhase4623(projectDir, '02-beta'); const aCovered = [...a.ownFiles, ...SHARED_DOCS_4623]; const bCovered = [...b.ownFiles, ...SHARED_DOCS_4623]; writeReport4623(a, aCovered, fingerprintViaCli(projectDir, a, aCovered)); writeReport4623(b, bCovered, fingerprintViaCli(projectDir, b, bCovered)); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed'); assert.equal(readVerificationStatus(b.phaseDir, NO_GIT_TIMES).status, 'passed'); // Phase A closes: its roadmap checkbox and its requirement flip. writeSharedDocs4623(projectDir, { roadmapDone: true, reqDone: true }); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed', 'the closing phase itself'); assert.equal(readVerificationStatus(b.phaseDir, NO_GIT_TIMES).status, 'passed', 'the untouched sibling phase'); assert.equal(isPhaseComplete(b.phaseDir).value.complete, true); // Real drift in B is still caught, and only in B. fs.appendFileSync(path.join(b.phaseDir, '02-01-PLAN.md'), '\nchanged\n'); assert.equal(readVerificationStatus(b.phaseDir, NO_GIT_TIMES).status, 'stale'); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed'); }); test('same-phase: the phase\'s own `requirements mark-complete` and `phase.complete` writes do not stale it', (t) => { const projectDir = createTempGitProject(); t.after(() => cleanup(projectDir)); writeSharedDocs4623(projectDir); const a = makePhase4623(projectDir, '01-alpha'); const covered = [...a.ownFiles, ...SHARED_DOCS_4623]; writeReport4623(a, covered, fingerprintViaCli(projectDir, a, covered)); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed'); // requirements mark-complete: checkbox + traceability cell. writeSharedDocs4623(projectDir, { reqDone: true }); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed'); // phase.complete: the phase's own roadmap status cell. writeSharedDocs4623(projectDir, { reqDone: true, roadmapDone: true }); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed'); }); test('a legacy v1 report keeps v1 semantics: passed while untouched, stale on a shared-doc edit, and the CLI re-fingerprint is the remedy', (t) => { const projectDir = createTempGitProject(); t.after(() => cleanup(projectDir)); writeSharedDocs4623(projectDir); const a = makePhase4623(projectDir, '01-alpha'); const covered = [...a.ownFiles, ...SHARED_DOCS_4623]; const v1 = computeCoveredDigest(projectDir, covered, 1); writeReport4623(a, covered, v1); // The upgrade alone must not stale an intact v1 report. assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed'); writeSharedDocs4623(projectDir, { roadmapDone: true }); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'stale', 'v1 hashed the shared docs; honour that'); // The documented remedy: recompute through the CLI, paste the result. const v2 = fingerprintViaCli(projectDir, a, covered); assert.match(v2, /^v2:/); writeReport4623(a, covered, v2); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed'); writeSharedDocs4623(projectDir, { roadmapDone: true, reqDone: true }); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'passed', 'and it lasts'); }); test('workstream scope through the READ path: the workstream\'s own ROADMAP.md flip leaves its phase passed; its PLAN edit stales it', (t) => { const projectDir = createTempGitProject(); t.after(() => cleanup(projectDir)); const wsRoot = path.join(projectDir, '.planning', 'workstreams', 'payments'); const phaseDir = path.join(wsRoot, 'phases', '01-alpha'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(wsRoot, 'ROADMAP.md'), '- [ ] **Phase 1: Alpha**\n'); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); const phase = { phaseDir, num: '01' }; const covered = [ '.planning/workstreams/payments/ROADMAP.md', '.planning/workstreams/payments/phases/01-alpha/01-01-PLAN.md', '.planning/workstreams/payments/phases/01-alpha/01-01-SUMMARY.md', ]; writeReport4623(phase, covered, fingerprintViaCli(projectDir, phase, covered)); assert.equal(readVerificationStatus(phaseDir, NO_GIT_TIMES).status, 'passed'); fs.writeFileSync(path.join(wsRoot, 'ROADMAP.md'), '- [x] **Phase 1: Alpha**\n'); assert.equal(readVerificationStatus(phaseDir, NO_GIT_TIMES).status, 'passed', 'workstream roadmap bookkeeping'); fs.appendFileSync(path.join(phaseDir, '01-01-PLAN.md'), 'changed\n'); assert.equal(readVerificationStatus(phaseDir, NO_GIT_TIMES).status, 'stale'); }); test('a phase directory that is not /phases/ nominates no extra root: implementation evidence beside it stays hashed', (t) => { const projectDir = createTempGitProject(); t.after(() => cleanup(projectDir)); const fakePhase = path.join(projectDir, 'src', 'phases', '01-fake'); fs.mkdirSync(fakePhase, { recursive: true }); fs.writeFileSync(path.join(projectDir, 'src', 'evidence.cts'), 'export const x = 1;\n'); fs.writeFileSync(path.join(fakePhase, '01-01-PLAN.md'), '# Plan\n'); const covered = ['src/evidence.cts', 'src/phases/01-fake/01-01-PLAN.md']; const digest = computeCoveredDigest(projectDir, covered, 2, { phaseDir: fakePhase }); fs.writeFileSync( path.join(fakePhase, '01-VERIFICATION.md'), `---\nstatus: passed\ncovered_files:\n${covered.map((f) => ` - ${f}`).join('\n')}\ncovered_digest: "${digest}"\n---\n`, ); assert.equal(readVerificationStatus(fakePhase, NO_GIT_TIMES).status, 'passed'); fs.writeFileSync(path.join(projectDir, 'src', 'evidence.cts'), 'export const x = 2;\n'); assert.equal(readVerificationStatus(fakePhase, NO_GIT_TIMES).status, 'stale', 'src/ must never be treated as a planning root'); }); test('a digest under an unknown fingerprint version is stale (fail closed)', (t) => { const projectDir = createTempGitProject(); t.after(() => cleanup(projectDir)); const a = makePhase4623(projectDir, '01-alpha'); writeReport4623(a, a.ownFiles, 'v9:sha256:' + 'a'.repeat(64)); assert.equal(readVerificationStatus(a.phaseDir, NO_GIT_TIMES).status, 'stale'); }); }); describe('#4623: verification.fingerprint CLI — --files forms and the phase-dir guard', () => { const { runMsdTools } = require('./helpers.cjs'); function setup() { const projectDir = createTempGitProject(); const a = makePhase4623(projectDir, '01-alpha'); fs.mkdirSync(path.join(projectDir, 'fastlane')); fs.writeFileSync(path.join(projectDir, 'fastlane', 'Fastfile'), 'lane :x do end\n'); fs.writeFileSync(path.join(projectDir, 'a.rb'), 'a\n'); fs.writeFileSync(path.join(projectDir, 'b.rb'), 'b\n'); return { projectDir, a }; } function run(projectDir, phaseDir, ...tokens) { return runMsdTools(['verification', 'fingerprint', phaseDir, ...tokens], projectDir); } function expectJson(res) { assert.equal(res.success, true, `expected success, got: ${res.output}${res.error}`); return JSON.parse(res.output); } test('AC2: --files a produces the same covered_files/covered_digest as the bare positional form', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); const positional = expectJson(run(projectDir, a.phaseDir, 'fastlane/Fastfile')); const flagged = expectJson(run(projectDir, a.phaseDir, '--files', 'fastlane/Fastfile')); assert.deepEqual(flagged, positional); assert.deepEqual(positional.covered_files, ['fastlane/Fastfile']); }); test('AC3: --files "a,b" and --files a --files b both resolve to covered_files [a, b], canonicalized like the bare form (AC4)', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); const positional = expectJson(run(projectDir, a.phaseDir, 'b.rb', 'a.rb')); assert.deepEqual(positional.covered_files, ['a.rb', 'b.rb']); assert.deepEqual(expectJson(run(projectDir, a.phaseDir, '--files', 'a.rb,b.rb')), positional); assert.deepEqual(expectJson(run(projectDir, a.phaseDir, '--files', 'a.rb', '--files', 'b.rb')), positional); assert.deepEqual(expectJson(run(projectDir, a.phaseDir, '--files=b.rb,a.rb')), positional); assert.deepEqual(expectJson(run(projectDir, a.phaseDir, 'a.rb', '--files', 'b.rb')), positional); assert.equal(positional.covered_digest, computeCoveredDigest(projectDir, ['a.rb', 'b.rb'])); }); test('--raw with --files prints just the digest', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); const res = run(projectDir, a.phaseDir, '--files', 'a.rb,b.rb', '--raw'); assert.equal(res.success, true, `expected success, got: ${res.output}${res.error}`); assert.equal(res.output.trim(), computeCoveredDigest(projectDir, ['a.rb', 'b.rb'])); }); test('AC5: a phase directory with zero covered files still fails closed with the existing error', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); const res = run(projectDir, a.phaseDir); assert.equal(res.success, false); assert.match(`${res.output}${res.error}`, /at least one covered file required/); }); test('an unrecognized flag is a usage error naming the flag — not "a covered file is missing"', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); const res = run(projectDir, a.phaseDir, '--file', 'a.rb'); assert.equal(res.success, false); assert.match(`${res.output}${res.error}`, /unknown flag --file\b/); assert.doesNotMatch(`${res.output}${res.error}`, /missing, unreadable/); }); test('an omitted phase dir (first argument is a covered file) is an error, not a plausible digest over the wrong set at exit 0', (t) => { const { projectDir } = setup(); t.after(() => cleanup(projectDir)); const res = runMsdTools(['verification', 'fingerprint', 'a.rb', 'b.rb', '--raw'], projectDir); assert.equal(res.success, false); assert.match(`${res.output}${res.error}`, /phase directory not found/); assert.doesNotMatch(res.output, /^v\d+:sha256:/); }); test('a declaration made only of shared planning documents is a named error, not "file missing"', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); writeSharedDocs4623(projectDir); const res = run(projectDir, a.phaseDir, '--files', SHARED_DOCS_4623.join(',')); assert.equal(res.success, false); assert.match(`${res.output}${res.error}`, /every covered file is a repo-wide planning document/); assert.doesNotMatch(`${res.output}${res.error}`, /missing, unreadable/); }); test('an all-shared declaration with a missing or directory member is a bad path first (generic error), not the named all-shared error', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); writeSharedDocs4623(projectDir); const res = run(projectDir, a.phaseDir, '.planning/ROADMAP.md', '.planning/MISSING.md'); assert.equal(res.success, false); assert.match(`${res.output}${res.error}`, /missing, unreadable/); assert.doesNotMatch(`${res.output}${res.error}`, /every covered file is a repo-wide planning document/); const dir = run(projectDir, a.phaseDir, '.planning/ROADMAP.md', '.planning/phases'); assert.equal(dir.success, false); assert.match(`${dir.output}${dir.error}`, /missing, unreadable/); }); test('a declared shared document stays listed in covered_files, and the emitted v2 digest survives its rewrite', (t) => { const { projectDir, a } = setup(); t.after(() => cleanup(projectDir)); writeSharedDocs4623(projectDir); const covered = [...a.ownFiles, ...SHARED_DOCS_4623]; const first = expectJson(run(projectDir, a.phaseDir, '--files', covered.join(','))); assert.deepEqual(first.covered_files, [...covered].sort()); assert.match(first.covered_digest, /^v2:/); assert.equal(first.covered_digest, computeCoveredDigest(projectDir, covered)); writeSharedDocs4623(projectDir, { roadmapDone: true, reqDone: true }); const second = expectJson(run(projectDir, a.phaseDir, ...covered)); assert.equal(second.covered_digest, first.covered_digest); }); }); // ─── #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 `/msd:…` 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 // `/msd:execute-phase`, which Codex does not install — the surface there is // `$msd-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 `/msd-…` assertions above were `/msd:…` 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 // `formatMsdSlash`, with callers passing `resolveRuntime(cwd)`. // // Coverage is the matrix the issue asked for — missing, unknown, gaps_found and // stale, against Codex (`$msd-…`) and a slash-hyphen runtime (`/msd-…`) — plus // the `phase complete` error path, not merely the router's return object. /** Codex installs `$msd-`; every other shipped runtime installs `/msd-`. */ const RUNTIMES = [ { id: 'codex', prefix: '$msd-' }, { id: 'cursor', prefix: '/msd-' }, ]; // 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(), 'msd-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}execute-phase 01`); }); test('passed has no next step and stays empty, not a bare prefix', () => { // Boundary: projecting an empty command must not emit `$msd-` / `/msd-`. 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('/msd:'), `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 `/msd:execute-phase`. assert.equal(readVerificationStatus(projPhaseDir).next_command, '/msd-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 { runMsdTools, 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 = runMsdTools(['phase', 'complete', '01'], projectDir); // The blocked-completion message goes to stderr, which runMsdTools // 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('/msd:'), `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 = runMsdTools(['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('/msd:'), `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 `msd_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 // (runMsdTools), not the in-process readVerificationStatus() helper used above. describe('#2868: verification status CLI drives the execute-phase stranded-phase resume', () => { const { runMsdTools, 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 = runMsdTools(['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 = runMsdTools(['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 = runMsdTools(['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, '..', 'msd-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, '..', 'msd-core', 'workflows', '_runtime-launcher.snippet.sh', ); const SHIM_ANCHOR = '_MSD_SHIM_NAME="msd-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('msd_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('msd_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 msd_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 msd_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'); const contentLines = content.split(/\r?\n/); const fences = scanFencedBlocks(contentLines) .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash') .map((b) => contentLines.slice(b.openLineIdx, b.closeLineIdx + 1).join('\n')); const statusFence = fences.find((f) => f.includes('msd_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', ); }); }); }); } // ─── #4806: unparseable VERIFICATION.md frontmatter is a parse error, not "missing" ── describe('#4806: unparseable VERIFICATION.md frontmatter reports a parse error, not missing', () => { test('a VERIFICATION.md whose frontmatter fails to parse reports status unparseable, not missing', () => { // The file EXISTS and verification ran — "missing" (and its next_command // re-running execute-phase) is a false statement about the phase. The // YAML syntax error is in the report, not in the phase's execution. const dir = mkPhaseDir('unparseable'); fs.writeFileSync(path.join(dir, '01-foo-VERIFICATION.md'), '---\nstatus: "passed\n---\n\n# Verification Report\n'); const result = readVerificationStatus(dir); assert.equal(result.status, 'unparseable', 'status must be unparseable'); assert.ok(result.next_action.includes('not parseable YAML'), 'next_action must name the YAML parse failure'); }); test('a well-formed control file still reports passed (unchanged)', () => { const dir = mkPhaseDir('control'); writeVerificationMd(dir, '01-foo-VERIFICATION.md', 'passed'); const result = readVerificationStatus(dir); assert.equal(result.status, 'passed'); }); });