// allow-test-rule: source-text-is-the-product // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. /** * GSD Tools Tests - Phase * * Consolidated from 20 → 4 test files (issue #3740). This file covers the * CJS phase CLI layer (phase.cjs + gsd-tools.cjs): add, remove, complete, * list, insert, and all regression tests for bugs in that layer. */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('node:os'); const { execFileSync } = require('node:child_process'); const { runNode } = require('./helpers/process-seam.cjs'); const { toLegacyResult } = require('./helpers/git-fixture.cjs'); // `phase complete` against a real STATE.md rewrite; matches the 60000ms bound // already used for the same CLI call elsewhere in this file (runPhaseComplete // above, and `run()` below at 15000ms for a lighter query-only call). const PHASE_COMPLETE_TIMEOUT_MS = 60000; const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); function normalizePhaseToken(token) { return String(token).replace(/\d+/g, (digits) => String(Number(digits))); } function phaseTokenFromDirName(name) { const match = name.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+[A-Z]?(?:\.\d+)*)/i); return match ? match[1] : null; } function writePassedVerificationForPhase(tmpDir, phase) { const phasesDir = path.join(tmpDir, '.planning', 'phases'); const wanted = normalizePhaseToken(phase); const phaseDirName = fs.readdirSync(phasesDir) .find((name) => normalizePhaseToken(phaseTokenFromDirName(name) || '') === wanted); assert.ok(phaseDirName, `expected phase directory for Phase ${phase}`); const phaseDir = path.join(phasesDir, phaseDirName); fs.writeFileSync( path.join(phaseDir, `${phase}-VERIFICATION.md`), ['---', 'status: passed', '---', '', '# Verification', ''].join('\n'), ); } function runVerifiedPhaseComplete(args, tmpDir, env) { const argv = Array.isArray(args) ? args : (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) .map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1')); const completeIdx = argv.findIndex((token, index) => token === 'complete' && argv[index - 1] === 'phase'); assert.notEqual(completeIdx, -1, `expected phase complete command, got ${argv.join(' ')}`); const phase = argv[completeIdx + 1]; assert.ok(phase, `expected phase number in command ${argv.join(' ')}`); writePassedVerificationForPhase(tmpDir, phase); return runGsdTools(args, tmpDir, env); } function writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus) { const planningDir = path.join(tmpDir, '.planning'); const phase1Dir = path.join(planningDir, 'phases', '01-foundation'); const phase2Dir = path.join(planningDir, 'phases', '02-api'); fs.mkdirSync(phase1Dir, { recursive: true }); fs.mkdirSync(phase2Dir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 1: Foundation', '- [ ] Phase 2: API', '', '### Phase 1: Foundation', '**Goal:** Setup', '**Plans:** 1 plans', '', '### Phase 2: API', '**Goal:** Build API', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/1 | Not started | - |', '| 02. API | 0/1 | Not started | - |', '', ].join('\n'), ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Current Plan:** 01-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working on phase 1', '', ].join('\n'), ); fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n'); if (verificationStatus !== null) { fs.writeFileSync( path.join(phase1Dir, '01-VERIFICATION.md'), [ '---', `status: ${verificationStatus}`, '---', '', '# Verification', '', ].join('\n'), ); } } describe('phases list command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('empty phases directory returns empty array', () => { const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.directories, [], 'directories should be empty'); assert.strictEqual(output.count, 0, 'count should be 0'); }); test('lists phase directories sorted numerically', () => { // Create out-of-order directories fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '10-final'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 3, 'should have 3 directories'); assert.deepStrictEqual( output.directories, ['01-foundation', '02-api', '10-final'], 'should be sorted numerically' ); }); test('handles decimal phases in sort order', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.1-hotfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.2-patch'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-ui'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.directories, ['02-api', '02.1-hotfix', '02.2-patch', '03-ui'], 'decimal phases should sort correctly between whole numbers' ); }); test('--type plans lists only PLAN.md files', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1'); fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(phaseDir, 'RESEARCH.md'), '# Research'); const result = runGsdTools('phases list --type plans', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.files.sort(), ['01-01-PLAN.md', '01-02-PLAN.md'], 'should list only PLAN files' ); }); test('--type summaries lists only SUMMARY.md files', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1'); fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary 2'); const result = runGsdTools('phases list --type summaries', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.files.sort(), ['01-01-SUMMARY.md', '01-02-SUMMARY.md'], 'should list only SUMMARY files' ); }); test('--phase filters to specific phase directory', () => { const phase01 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); const phase02 = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phase01, { recursive: true }); fs.mkdirSync(phase02, { recursive: true }); fs.writeFileSync(path.join(phase01, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phase02, '02-01-PLAN.md'), '# Plan'); const result = runGsdTools('phases list --type plans --phase 01', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.files, ['01-01-PLAN.md'], 'should only list phase 01 plans'); assert.strictEqual(output.phase_dir, 'foundation', 'should report phase name without number prefix'); }); }); // ───────────────────────────────────────────────────────────────────────────── // roadmap get-phase command // ───────────────────────────────────────────────────────────────────────────── // #1729 regression: a phase header may carry a parenthetical tag BEFORE the // colon — `### Phase 26 (Cluster B): Title`. The header regexes built // `Phase\s+` immediately followed by the colon delimiter, so the resolver // returned found:false and phase commands silently no-op'd (the failure is // silent — not-found, not an error — so an author can lose work without a // signal). The fix injects a shared OPTIONAL_PHASE_TAG_SOURCE fragment at every // header call site, mirroring how `[...]` is already tolerated before `Phase`. // // Parity assertion (per the #3537/#3599 generative-fix discipline): a pre-colon // tag must resolve the SAME phase as the equivalent post-colon tag, padding // tolerance must survive, and the optional tag must not enable cross-phase // false matches. A shared seam + parity test keeps the next call site from // drifting back undetected. describe('#1729 regression: parenthetical tag before the colon in a phase header', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); function writeRoadmap(lines) { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), ['# Roadmap', ''].concat(lines, ['']).join('\n'), ); } function getPhase(query) { const result = runGsdTools(['roadmap', 'get-phase', String(query)], tmpDir); assert.ok(result.success, `get-phase ${query} failed: ${result.error}`); return JSON.parse(result.output); } test('resolves a header whose tag sits before the colon (the reported bug)', () => { writeRoadmap([ '### Phase 26 (Cluster B): Engine-adapter caveats', 'Plans: 2', ]); const phase = getPhase(26); assert.equal(phase.found, true, 'pre-colon-tagged phase must resolve'); assert.equal( phase.phase_name, 'Engine-adapter caveats', 'the pre-colon tag must be excluded from the resolved name', ); }); test('a pre-colon tag resolves the same phase as a post-colon tag', () => { // Post-colon placement is the documented workaround and already worked. // Both placements must resolve found:true for the same phase number; the // only contracted difference is that a post-colon tag is part of the title. writeRoadmap(['### Phase 26: Engine-adapter caveats (Cluster B)', 'Plans: 2']); const post = getPhase(26); assert.equal(post.found, true); assert.equal(post.phase_name, 'Engine-adapter caveats (Cluster B)'); writeRoadmap(['### Phase 26 (Cluster B): Engine-adapter caveats', 'Plans: 2']); const pre = getPhase(26); assert.equal(pre.found, true, 'pre-colon placement must resolve like post-colon'); assert.equal(pre.phase_number, post.phase_number); }); test('padding tolerance (#3537) survives: both `6` and `06` resolve a tagged header', () => { writeRoadmap(['### Phase 6 (Cluster B): Padded test', 'Plans: 1']); const unpadded = getPhase(6); const padded = getPhase('06'); assert.equal(unpadded.found, true, 'unpadded query must resolve'); assert.equal(padded.found, true, 'padded query must resolve'); assert.equal(padded.phase_name, unpadded.phase_name, 'padded/unpadded must agree'); assert.equal(unpadded.phase_name, 'Padded test'); }); test('decimal sub-phase headers tolerate a pre-colon tag', () => { writeRoadmap([ '### Phase 26 (Cluster B): Base', 'Plans: 1', '', '### Phase 26.1 (Sub tag): Decimal subphase', 'Plans: 1', ]); const sub = getPhase('26.1'); assert.equal(sub.found, true, 'decimal sub-phase with pre-colon tag must resolve'); assert.equal(sub.phase_name, 'Decimal subphase'); }); test('the optional tag does not enable a cross-phase false match', () => { // `0*2` must not latch onto `Phase 26 (...)`: querying phase 2 against a // roadmap that only has phase 26 must still report not-found. writeRoadmap(['### Phase 26 (Cluster B): Engine caveats', 'Plans: 1']); assert.equal(getPhase(2).found, false, 'phase 2 must not match phase 26'); assert.equal(getPhase(26).found, true, 'sanity: phase 26 still resolves'); }); test('exposes a shared OPTIONAL_PHASE_TAG_SOURCE seam to prevent call-site drift', () => { const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); assert.equal( typeof phaseId.OPTIONAL_PHASE_TAG_SOURCE, 'string', 'the shared tag fragment must be exported so every header site composes it', ); const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}:`); assert.ok(re.test('### Phase 26 (Cluster B): X'), 'seam matches a pre-colon tag'); assert.ok(re.test('### Phase 26: X'), 'seam stays optional when no tag is present'); }); test('#2128: the pre-colon tag is length-bounded so the tag clause cannot ReDoS', () => { // The tag body `[^)\n]*` was unbounded, making the optional-group + /g scan // quadratic on adversarial ROADMAP.md/STATE.md (a long run of `(` after a // header). Bounding it to {0,200} keeps the match linear; a 200-char tag body // still matches (real tags are a handful of chars), 201 does not. const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`); // Boundary coverage (CLAUDE.md): limit-1, limit, limit+1. assert.ok(re.test(`### Phase 26 (${'x'.repeat(199)}): T`), 'a 199-char tag body (limit-1) is within the bound'); assert.ok(re.test(`### Phase 26 (${'x'.repeat(200)}): T`), 'a 200-char tag body (limit) is within the bound'); assert.ok(!re.test(`### Phase 26 (${'x'.repeat(201)}): T`), 'a 201-char tag body (limit+1) exceeds the bound'); // Linearity guard: the adversarial input that was ~18.8s unbounded resolves // near-instantly now. Assert bounded work, not wall-clock (no clock seam): // the bounded source contains an explicit upper repetition limit. assert.match(phaseId.OPTIONAL_PHASE_TAG_SOURCE, /\{0,\d+\}/, 'tag body must carry an explicit upper bound'); }); test('enumeration (roadmap analyze) lists a pre-colon-tagged phase, not just the resolver', () => { // The resolver (get-phase) and the capture-all enumeration regexes are // separate code paths. Fixing only the resolver left `roadmap analyze` // silently dropping a tagged phase from its phase list — wrong phase_count, // progress_percent, and next_phase. Both a tagged and an untagged phase must // appear so the enumeration is coherent with the resolver. writeRoadmap([ '### Phase 26 (Cluster B): Engine-adapter caveats', 'Plans: 1', '', '### Phase 27: Coordinator playbook', 'Plans: 1', ]); const result = runGsdTools(['roadmap', 'analyze'], tmpDir); assert.ok(result.success, `roadmap analyze failed: ${result.error}`); const analysis = JSON.parse(result.output); const numbers = analysis.phases.map((p) => p.number); assert.ok(numbers.includes('26'), 'tagged phase 26 must appear in enumeration'); assert.ok(numbers.includes('27'), 'untagged phase 27 must appear in enumeration'); assert.equal(analysis.phase_count, 2, 'tagged phase must count toward phase_count'); const p26 = analysis.phases.find((p) => p.number === '26'); assert.equal(p26.name, 'Engine-adapter caveats', 'pre-colon tag must be excluded from the enumerated name'); }); test('phase remove renumbers a later tagged header and preserves its tag', () => { // The renumber-on-removal rewrite (phase.cts) captured `(num)(\s*:)`, so a // later pre-colon-tagged header was skipped — leaving a stale/duplicate // number after an earlier phase was removed. The tag must survive the // rewrite (it is folded into the re-emitted suffix, not dropped). fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '### Phase 2: Auth', '**Goal:** Authentication', '', '### Phase 3 (Cluster B): Features', '**Goal:** Core features', '', ].join('\n'), ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); const result = runGsdTools('phase remove 2', tmpDir); assert.ok(result.success, `phase remove failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( /###\s*Phase 2 \(Cluster B\): Features/.test(roadmap), `tagged header must renumber 3->2 and keep its tag; got:\n${roadmap}`, ); assert.ok(!/Phase 3 \(Cluster B\)/.test(roadmap), 'old number 3 must be gone'); }); test('the literal enumeration mirror stays equivalent to the exported seam (drift guard)', () => { // Resolver sites compose OPTIONAL_PHASE_TAG_SOURCE; literal enumeration sites // inline `(?:\s*\([^)\n]{0,200}\))?`. If one is edited without the other the // two header families silently diverge (the body is bounded to {0,200} in // both since #2128 — a ReDoS fix that MUST stay in lockstep). Assert // behavioral equivalence over a representative header corpus. const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]{0,200}\\))?'; const seam = new RegExp(`^Phase\\s+26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`); const mirror = new RegExp(`^Phase\\s+26${LITERAL_MIRROR}\\s*:`); for (const sample of [ 'Phase 26: X', 'Phase 26 (Cluster B): X', 'Phase 26 (a) (b): X', 'Phase 26 (unterminated: X', 'Phase 26 : X', ]) { assert.equal( seam.test(sample), mirror.test(sample), `seam and literal mirror must agree on: ${JSON.stringify(sample)}`, ); } }); }); describe('phase next-decimal command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('returns X.1 when no decimal phases exist', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '07-next'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.next, '06.1', 'should return 06.1'); assert.deepStrictEqual(output.existing, [], 'no existing decimals'); }); test('increments from existing decimal phases', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-hotfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-patch'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.next, '06.3', 'should return 06.3'); assert.deepStrictEqual(output.existing, ['06.1', '06.2'], 'lists existing decimals'); }); test('handles gaps in decimal sequence', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-first'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-third'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Should take next after highest, not fill gap assert.strictEqual(output.next, '06.4', 'should return 06.4, not fill gap at 06.2'); }); test('handles single-digit phase input', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true }); const result = runGsdTools('phase next-decimal 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.next, '06.1', 'should normalize to 06.1'); assert.strictEqual(output.base_phase, '06', 'base phase should be padded'); }); test('returns error if base phase does not exist', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-start'), { recursive: true }); const result = runGsdTools('phase next-decimal 06', tmpDir); assert.ok(result.success, `Command should succeed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'base phase not found'); assert.strictEqual(output.next, '06.1', 'should still suggest 06.1'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase-plan-index command // ───────────────────────────────────────────────────────────────────────────── describe('phase-plan-index command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('empty phase directory returns empty plans array', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase, '03', 'phase number correct'); assert.deepStrictEqual(output.plans, [], 'plans should be empty'); assert.deepStrictEqual(output.waves, {}, 'waves should be empty'); assert.deepStrictEqual(output.incomplete, [], 'incomplete should be empty'); assert.strictEqual(output.has_checkpoints, false, 'no checkpoints'); assert.ok(output.warning === undefined, 'truly empty dir must not emit a warning'); }); test('phase dir whose slug leads with a year still resolves and indexes plans (#2232)', () => { // Roadmap phase name "2026 Photos & Performance" → dir // "14-2026-photos-performance". extractPhaseToken over-collected the year // into the token ("14-2026"), so phase-plan-index reported plans: [] while // the plans existed on disk. const phaseDir = path.join(tmpDir, '.planning', 'phases', '14-2026-photos-performance'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '14-01-PLAN.md'), '---\nwave: 1\n---\n'); fs.writeFileSync(path.join(phaseDir, '14-02-PLAN.md'), '---\nwave: 1\n---\n'); const result = runGsdTools('phase-plan-index 14', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 2, 'plans found despite year-leading slug'); assert.ok(output.warning === undefined, `canonical plans must not warn, got: ${output.warning}`); }); // #2893 — when the planner produces filenames that don't match the canonical // `{padded_phase}-{NN}-PLAN.md` contract, the executor used to silently see // plan_count: 0 with no signal. Now the response must include a `warning` // field naming every offender, so the user gets an actionable error instead // of "execute-phase blocked, no clue why". test('non-canonical plan filenames surface a warning naming each offender (#2893)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // The reporter's exact symptom: planner wrote `{phase-id}-PLAN-{N}-{slug}.md`. fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n'); fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n'); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 0, 'non-canonical files are not silently accepted'); assert.ok(typeof output.warning === 'string', 'warning field must be present'); assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender'); assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender'); assert.ok( output.warning.includes('{padded_phase}-{NN}-PLAN.md'), 'warning cites the canonical pattern so user knows what to rename to', ); }); test('canonical plans suppress the warning even alongside derivative files (#2893)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // Canonical plan + the legitimate derivative artifacts the planner emits. fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n'); fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n'); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n'); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 1, 'canonical plan detected'); assert.ok( output.warning === undefined, `outline and pre-bounce files must not trigger the warning, got: ${output.warning}`, ); }); // #2893 parity — find-phase reads the same phase directory and applies the // same canonical filter, so it must emit the same warning shape. Without // these tests the two code paths could silently diverge. test('find-phase: non-canonical plan filenames surface the same warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n'); fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n'); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'phase directory found'); assert.deepStrictEqual(output.plans, [], 'non-canonical files are not silently accepted'); assert.ok(typeof output.warning === 'string', 'warning field must be present'); assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender'); assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender'); assert.ok( output.warning.includes('{padded_phase}-{NN}-PLAN.md'), 'warning cites the canonical pattern', ); }); test('find-phase: canonical plans + derivatives suppress the warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n'); fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n'); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n'); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.plans, ['03-01-PLAN.md'], 'canonical plan detected'); assert.ok( output.warning === undefined, `outline and pre-bounce files must not trigger the warning, got: ${output.warning}`, ); }); // #2893 parity — `phases list --type plans` aggregates across phase dirs // and prefixes each warning with `${dir}: ` so the user can locate the // offending phase. Test mirrors the find-phase pair but accounts for that // prefix in the assertion. test('phases list --type plans: non-canonical filenames surface a per-dir warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n'); fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n'); const result = runGsdTools('phases list --type plans --phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.files, [], 'non-canonical files are not silently accepted'); assert.ok(typeof output.warning === 'string', 'warning field must be present'); assert.ok(output.warning.includes('03-api:'), 'warning is prefixed with the offending phase dir'); assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender'); assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender'); assert.ok( output.warning.includes('{padded_phase}-{NN}-PLAN.md'), 'warning cites the canonical pattern', ); }); test('phases list --type plans: canonical plans suppress the warning (#2893 parity)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n'); fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n'); fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n'); const result = runGsdTools('phases list --type plans --phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.files, ['03-01-PLAN.md'], 'canonical plan detected'); assert.ok( output.warning === undefined, `outline and pre-bounce files must not trigger the warning, got: ${output.warning}`, ); }); test('extracts single plan with frontmatter', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '03-01-PLAN.md'), `--- wave: 1 autonomous: true objective: Set up database schema files-modified: [prisma/schema.prisma, src/lib/db.ts] --- ## Task 1: Create schema ## Task 2: Generate client ` ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 1, 'should have 1 plan'); assert.strictEqual(output.plans[0].id, '03-01', 'plan id correct'); assert.strictEqual(output.plans[0].wave, 1, 'wave extracted'); assert.strictEqual(output.plans[0].autonomous, true, 'autonomous extracted'); assert.strictEqual(output.plans[0].objective, 'Set up database schema', 'objective extracted'); assert.deepStrictEqual(output.plans[0].files_modified, ['prisma/schema.prisma', 'src/lib/db.ts'], 'files extracted'); assert.strictEqual(output.plans[0].task_count, 2, 'task count correct'); assert.strictEqual(output.plans[0].has_summary, false, 'no summary yet'); }); test('groups multiple plans by wave (DAG-bucketing: 03-03 depends on 03-01 and 03-02)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '03-01-PLAN.md'), [ '---', 'wave: 1', 'autonomous: true', 'objective: Database setup', 'depends_on: []', '---', '', '## Task 1: Schema', ].join('\n') ); fs.writeFileSync( path.join(phaseDir, '03-02-PLAN.md'), [ '---', 'wave: 1', 'autonomous: true', 'objective: Auth setup', 'depends_on: []', '---', '', '## Task 1: JWT', ].join('\n') ); fs.writeFileSync( path.join(phaseDir, '03-03-PLAN.md'), [ '---', 'wave: 2', 'autonomous: false', 'objective: API routes', 'depends_on:', ' - 03-01', ' - 03-02', '---', '', '## Task 1: Routes', ].join('\n') ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans.length, 3, 'should have 3 plans'); assert.deepStrictEqual(output.waves['1'], ['03-01', '03-02'], 'wave 1 has 2 plans'); assert.deepStrictEqual(output.waves['2'], ['03-03'], 'wave 2 has 1 plan'); // No mismatch warning: declared wave 2 matches topo level 2 assert.strictEqual(output.warnings, undefined, 'no warnings when declared wave matches DAG'); }); test('detects incomplete plans (no matching summary)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan with summary fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), `---\nwave: 1\n---\n## Task 1`); fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`); // Plan without summary fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), `---\nwave: 2\n---\n## Task 1`); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans[0].has_summary, true, 'first plan has summary'); assert.strictEqual(output.plans[1].has_summary, false, 'second plan has no summary'); assert.deepStrictEqual(output.incomplete, ['03-02'], 'incomplete list correct'); }); test('phase-plan-index matches descriptive plan with prefix summary (#3101)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1`); fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plans[0].has_summary, true, 'descriptive plan should match prefix summary'); assert.deepStrictEqual(output.incomplete, [], 'plan should not be marked incomplete'); }); // #3266 CR — depends_on canonical-id mismatch: a plan named // '03-01-auth-hardening-PLAN.md' is stored with id '03-01-auth-hardening', // but a dependency declared as '03-01' was never resolving to it, silently // putting the dependent plan in the same wave as its prerequisite. test('depends_on short canonical prefix resolves against descriptive plan filename (#3266)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan 01: descriptive filename — id becomes '03-01-auth-hardening' fs.writeFileSync( path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1\n`, ); // Plan 02: depends on the canonical prefix '03-01' (not the full stem) fs.writeFileSync( path.join(phaseDir, '03-02-followup-PLAN.md'), `---\ndepends_on:\n - '03-01'\n---\n## Task 1\n`, ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; // Plan 01 must be in an earlier wave than plan 02 const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-02'))); assert.ok(wave01 !== undefined, 'plan 03-01-auth-hardening should appear in waves'); assert.ok(wave02 !== undefined, 'plan 03-02-followup should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `03-02 must be in a later wave than 03-01 (got wave01=${wave01}, wave02=${wave02})`, ); }); test('detects checkpoints (autonomous: false)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '03-01-PLAN.md'), `--- wave: 1 autonomous: false objective: Manual review needed --- ## Task 1: Review ` ); const result = runGsdTools('phase-plan-index 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.has_checkpoints, true, 'should detect checkpoint'); assert.strictEqual(output.plans[0].autonomous, false, 'plan marked non-autonomous'); }); test('phase not found returns error', () => { const result = runGsdTools('phase-plan-index 99', tmpDir); assert.ok(result.success, `Command should succeed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.error, 'Phase not found', 'should report phase not found'); }); // #3785 — case-insensitive depends_on resolution test('#3785: depends_on reference with different case resolves to correct plan', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '20-case-insensitive'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: filename uses uppercase suffix — plan ID becomes '20-01-Auth' fs.writeFileSync( path.join(phaseDir, '20-01-Auth-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan A.\n`, ); // Plan B: depends_on uses lowercase — must still resolve to Plan A fs.writeFileSync( path.join(phaseDir, '20-02-PLAN.md'), `---\nwave: 2\nautonomous: true\ndepends_on:\n - 20-01-auth\n---\nPlan B.\n`, ); const result = runGsdTools('phase-plan-index 20', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-02'))); assert.ok(wave01 !== undefined, 'plan 20-01-Auth should appear in waves'); assert.ok(wave02 !== undefined, 'plan 20-02 should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `20-02 must be in a later wave than 20-01 (got wave01=${wave01}, wave02=${wave02}) — DAG edge dropped (case mismatch)`, ); // Lock canonical casing: plan ID must be preserved as-is from the filename, not lowercased. const planA = output.plans.find(p => p.id.startsWith('20-01')); assert.strictEqual(planA.id, '20-01-Auth', 'canonical casing must be preserved in plan ID'); // depends_on output must use canonical plan ID, not the user-typed casing. const planB = output.plans.find(p => p.id === '20-02'); assert.deepStrictEqual(planB.depends_on, ['20-01-Auth'], 'depends_on output must resolve to canonical ID casing'); // No unresolved-dep warning const warnings = output.warnings ?? []; assert.ok( !warnings.some(w => /unresolved/i.test(w)), `Unexpected unresolved-dep warning: ${JSON.stringify(warnings)}`, ); }); // #3785 adversarial: two plan IDs that are identical when case-folded must // fail fast with a clear error instead of silently routing edges to the wrong plan. // This test can only run on Linux where the filesystem is case-sensitive. // On macOS/Windows (case-insensitive FS), writing both files silently collapses // them to one file, so the collision scenario cannot be triggered via disk. test('#3785 adversarial: two plan IDs differing only by case produce a collision error', { skip: process.platform !== 'linux' ? 'case-insensitive filesystem — collision test requires Linux' : false, }, () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '21-collision'); fs.mkdirSync(phaseDir, { recursive: true }); // '21-01-auth-PLAN.md' → id '21-01-auth' // '21-01-Auth-PLAN.md' → id '21-01-Auth' // Both lowercase to '21-01-auth' — collision. fs.writeFileSync( path.join(phaseDir, '21-01-auth-PLAN.md'), `---\nautonomous: true\ndepends_on: []\n---\nlowercase.\n`, ); fs.writeFileSync( path.join(phaseDir, '21-01-Auth-PLAN.md'), `---\nautonomous: true\ndepends_on: []\n---\nuppercase.\n`, ); const result = runGsdTools('phase-plan-index 21', tmpDir); // The command must exit with an error (non-success) naming the collision. assert.ok(!result.success, 'phase-plan-index must fail when two plan IDs collide under case-folding'); assert.ok( /collision/i.test(result.error ?? result.output ?? ''), `Error output must mention 'collision', got: ${result.error ?? result.output}`, ); }); // #3785 — all-uppercase depends_on value resolves to an all-lowercase plan ID test('#3785: all-uppercase depends_on ref resolves to lowercase plan ID', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '22-uppercase-dep'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: all-lowercase plan ID fs.writeFileSync( path.join(phaseDir, '22-01-setup-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan A.\n`, ); // Plan B: depends_on uses ALL-UPPERCASE — must still route the DAG edge to Plan A fs.writeFileSync( path.join(phaseDir, '22-02-PLAN.md'), `---\nwave: 2\nautonomous: true\ndepends_on:\n - 22-01-SETUP\n---\nPlan B.\n`, ); const result = runGsdTools('phase-plan-index 22', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-02'))); assert.ok(wave01 !== undefined, 'plan 22-01-setup should appear in waves'); assert.ok(wave02 !== undefined, 'plan 22-02 should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `22-02 must be in a later wave than 22-01 — all-uppercase dep should route correctly (got wave01=${wave01}, wave02=${wave02})`, ); // depends_on output must use canonical plan ID (lowercase as-on-disk), not the uppercase ref const planB = output.plans.find(p => p.id === '22-02'); assert.deepStrictEqual(planB.depends_on, ['22-01-setup'], 'depends_on output must resolve to canonical lowercase ID'); }); // #3785 — external (cross-phase) depends_on reference is kept as-is in output // The Pass 3 mapping must return the original dep string when planMap has no entry for it. test('#3785: external cross-phase depends_on ref is preserved as-is in output', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '23-external-dep'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: references a plan in a different phase (01-some-other-phase) — planMap won't have it fs.writeFileSync( path.join(phaseDir, '23-01-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on:\n - 01-01-prereq\n---\nPlan A.\n`, ); const result = runGsdTools('phase-plan-index 23', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const planA = output.plans.find(p => p.id.startsWith('23-01')); assert.ok(planA !== undefined, 'plan 23-01 should appear in output'); // External dep must be preserved verbatim — not dropped, not resolved assert.deepStrictEqual(planA.depends_on, ['01-01-prereq'], 'external cross-phase dep must be kept as-is in output'); }); // #3785 — mixed-case canonical prefix in depends_on resolves via canonicalToId lookup // e.g. depends_on: '22-01-SETUP' where extractCanonicalPlanId gives '22-01' keyed lowercase test('#3785: mixed-case short canonical prefix in depends_on resolves via canonicalToId', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '24-canon-case'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan A: descriptive filename — id becomes '24-01-auth-hardening' fs.writeFileSync( path.join(phaseDir, '24-01-auth-hardening-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan A.\n`, ); // Plan B: depends_on uses an uppercase short prefix '24-01' — canonicalToId maps '24-01' → '24-01-auth-hardening' fs.writeFileSync( path.join(phaseDir, '24-02-followup-PLAN.md'), `---\nwave: 2\nautonomous: true\ndepends_on:\n - '24-01'\n---\nPlan B.\n`, ); const result = runGsdTools('phase-plan-index 24', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const waves = output.waves; const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-01'))); const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-02'))); assert.ok(wave01 !== undefined, '24-01-auth-hardening should appear in waves'); assert.ok(wave02 !== undefined, '24-02-followup should appear in waves'); assert.ok( Number(wave01) < Number(wave02), `24-02 must be in a later wave than 24-01 via canonicalToId lookup (got wave01=${wave01}, wave02=${wave02})`, ); // depends_on output: '24-01' is the canonical prefix, not in planMap directly, so falls back to dep as-is const planB = output.plans.find(p => p.id === '24-02-followup'); assert.ok(planB !== undefined, '24-02-followup plan must be in output'); // The dep '24-01' is not a planMap key (full id is '24-01-auth-hardening'), so output keeps '24-01' assert.deepStrictEqual(planB.depends_on, ['24-01'], 'short canonical prefix dep falls through to as-is in Pass 3 output'); }); // #3785 — plans with no depends_on (empty array) still emit correct output without errors test('#3785: plans with undefined/empty depends_on emit empty array without errors', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '25-no-deps'); fs.mkdirSync(phaseDir, { recursive: true }); // Plan with no depends_on key at all fs.writeFileSync( path.join(phaseDir, '25-01-PLAN.md'), `---\nwave: 1\nautonomous: true\n---\nPlan A, no deps.\n`, ); // Plan with explicit empty depends_on array fs.writeFileSync( path.join(phaseDir, '25-02-PLAN.md'), `---\nwave: 1\nautonomous: true\ndepends_on: []\n---\nPlan B, explicit empty deps.\n`, ); const result = runGsdTools('phase-plan-index 25', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const plan01 = output.plans.find(p => p.id === '25-01'); const plan02 = output.plans.find(p => p.id === '25-02'); assert.ok(plan01 !== undefined, 'plan 25-01 should appear in output'); assert.ok(plan02 !== undefined, 'plan 25-02 should appear in output'); assert.deepStrictEqual(plan01.depends_on, [], 'plan with no depends_on key must emit empty array'); assert.deepStrictEqual(plan02.depends_on, [], 'plan with explicit empty depends_on must emit empty array'); // Both independent plans land in the same wave assert.deepStrictEqual(output.waves['1'], ['25-01', '25-02'], 'both no-dep plans should be in wave 1'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase-plan-index — canonical XML format (template-aligned) // ───────────────────────────────────────────────────────────────────────────── describe('phase-plan-index canonical format', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('files_modified: underscore key is parsed correctly', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- wave: 1 autonomous: true files_modified: [src/App.tsx, src/index.ts] --- Build main application shell Purpose: Entry point Output: App component Task 1: Create App component src/App.tsx Create component npm run build Component renders ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.plans[0].files_modified, ['src/App.tsx', 'src/index.ts'], 'files_modified with underscore should be parsed' ); }); test('objective: extracted from XML tag, not frontmatter', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- wave: 1 autonomous: true files_modified: [] --- Build main application shell Purpose: Entry point for the SPA Output: App.tsx with routing Task 1: Scaffold src/App.tsx Create shell build passes App renders ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.plans[0].objective, 'Build main application shell', 'objective should come from XML tag first line' ); }); test('task_count: counts XML tags', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- wave: 1 autonomous: true files_modified: [] --- Create UI components Task 1: Header src/Header.tsx Create header build Header renders Task 2: Footer src/Footer.tsx Create footer build Footer renders UI components Visit localhost:3000 Type approved ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.plans[0].task_count, 3, 'should count all 3 XML tags' ); }); test('all three fields work together in canonical plan format', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '04-01-PLAN.md'), `--- phase: 04-ui plan: 01 type: execute wave: 1 depends_on: [] files_modified: [src/components/Chat.tsx, src/app/api/chat/route.ts] autonomous: true requirements: [R1, R2] --- Implement complete Chat feature as vertical slice. Purpose: Self-contained chat that can run parallel to other features. Output: Chat component, API endpoints. @~/.claude/gsd-core/workflows/execute-plan.md @.planning/PROJECT.md @.planning/ROADMAP.md Task 1: Create Chat component src/components/Chat.tsx Build chat UI with message list and input npm run build Chat component renders messages Task 2: Create Chat API src/app/api/chat/route.ts GET /api/chat and POST /api/chat endpoints curl tests pass CRUD operations work - [ ] npm run build succeeds - [ ] API endpoints respond correctly ` ); const result = runGsdTools('phase-plan-index 04', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const plan = output.plans[0]; assert.strictEqual(plan.objective, 'Implement complete Chat feature as vertical slice.', 'objective from XML tag'); assert.deepStrictEqual(plan.files_modified, ['src/components/Chat.tsx', 'src/app/api/chat/route.ts'], 'files_modified with underscore'); assert.strictEqual(plan.task_count, 2, 'task_count from XML tags'); }); }); // ───────────────────────────────────────────────────────────────────────────── // state-snapshot command // ───────────────────────────────────────────────────────────────────────────── describe('phase add command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('adds phase after highest existing', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API --- ` ); const result = runGsdTools('phase add User Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 3, 'should be phase 3'); assert.strictEqual(output.slug, 'user-dashboard'); // Verify directory created assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')), 'directory should be created' ); // Verify ROADMAP updated const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase'); assert.ok(roadmap.includes('**Depends on:** Phase 2'), 'should depend on previous'); }); test('handles empty roadmap', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n` ); const result = runGsdTools('phase add Initial Setup', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 1, 'should be phase 1'); }); test('phase add includes **Requirements**: TBD in new ROADMAP entry', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const result = runGsdTools('phase add User Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('**Requirements**: TBD'), 'new phase entry should include Requirements TBD'); }); test('phase add ignores --raw instead of persisting it in the description', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const result = runGsdTools(['phase', 'add', '--raw', 'User', 'Dashboard'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('### Phase 2: User Dashboard'), 'description should exclude --raw'); assert.ok(!roadmap.includes('--raw'), 'raw flag must not be persisted into ROADMAP.md'); }); test('phase add rejects unsupported flags and dangling --id', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const unsupported = runGsdTools(['phase', 'add', '--unknown', 'Dashboard'], tmpDir); assert.ok(!unsupported.success, 'unsupported flags should fail'); assert.match(unsupported.error, /phase add does not support --unknown/); const dangling = runGsdTools(['phase', 'add', 'Dashboard', '--id'], tmpDir); assert.ok(!dangling.success, 'dangling --id should fail'); assert.match(dangling.error, /--id requires a value/); }); test('skips 999.x backlog phases when calculating next phase number', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API ### Phase 3: UI **Goal:** Build UI ### Phase 999.1: Future Idea A **Goal:** Backlog item ### Phase 999.2: Future Idea B **Goal:** Backlog item --- ` ); const result = runGsdTools('phase add Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 4, 'should be phase 4, not 1000'); assert.strictEqual(output.slug, 'dashboard'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-dashboard')), 'directory should be 04-dashboard, not 1000-dashboard' ); }); test('CJS scanner [999, 1000] fixture: skips exactly 999 and returns 1001 (regression #3774)', () => { // Locks the BLOCKER fix in phase.cjs: guards at :610, :624, :688, :698 must // use === 999 (not >= 999). With >= 999, phase 1000 is excluded from the // max-scan and the result collapses back toward 1 instead of 1001. // // GSD_WORKSTREAM=ws1 forces the CJS fallback in phase-command-router.cjs. // When GSD_WORKSTREAM is set, planningDir resolves to // .planning/workstreams// — so ROADMAP.md and phases/ live there. const ws = 'ws1'; const planningBase = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(planningBase, 'phases'), { recursive: true }); fs.writeFileSync( path.join(planningBase, 'ROADMAP.md'), [ '# Roadmap', '', '## Current Milestone: v1.0', '', '### Phase 999: Backlog', '', '**Goal:** Backlog sentinel', '**Plans:** 0 plans', '', '### Phase 1000: First Four-Digit Phase', '', '**Goal:** First canonical phase above backlog sentinel', '**Requirements**: TBD', '**Plans:** 1 plans', '', 'Plans:', '- [x] 1000-01 (initial work)', '', '---', '*Last updated: 2026-05-21*', '', ].join('\n') ); // Create matching phase directories on disk (inside the workstream planning dir) fs.mkdirSync(path.join(planningBase, 'phases', '999-backlog'), { recursive: true }); fs.mkdirSync(path.join(planningBase, 'phases', '1000-first-four-digit'), { recursive: true }); const result = runGsdTools('phase add After One Thousand', tmpDir, { GSD_WORKSTREAM: ws }); assert.ok(result.success, `CJS phase add failed: ${result.error}`); const output = JSON.parse(result.output); // Must be 1001: skips 999 (backlog sentinel), keeps 1000, adds 1. // With the old >= 999 guard: phase 1000 is excluded → max stays 0 → result = 1. assert.strictEqual(output.phase_number, 1001, 'CJS scanner must return 1001, not 1 (regression #3774)'); assert.ok( fs.existsSync(path.join(planningBase, 'phases', '1001-after-one-thousand')), 'directory should be 1001-after-one-thousand' ); }); // #2390 — phase.add title/goal split-or-warn regression coverage. test('phase add with a short, title-shaped description has no warning key', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const result = runGsdTools(['phase', 'add', 'Add auth'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.name, 'Add auth'); assert.ok(!('warning' in output), 'short title-shaped description should not produce a warning'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('### Phase 2: Add auth'), 'roadmap should include the new phase header'); }); test('phase add with a goal-shaped (long, multi-sentence) description surfaces a warning but still creates the phase (regression #2390)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const goalShaped = 'Add a comprehensive authentication and authorization system with OAuth2 support. ' + 'This will also include session management and rate limiting for the public API.'; const result = runGsdTools(['phase', 'add', goalShaped], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Negative proof: before the #2390 fix, phase.add emitted no `warning` key // for ANY description, however goal-shaped -- the paragraph landed silently // in the phase title with no signal in the JSON result. This assertion // fails on the pre-fix tree. assert.ok( typeof output.warning === 'string' && output.warning.length > 0, 'goal-shaped description should surface a warning field' ); assert.match(output.warning, /goal-shaped/); // The phase must still be created -- the warning surfaces the gap, it // does not block the write or mangle ROADMAP.md (preserves the two-layer // slash-command vs. raw-CLI interface: the CLI stays a strict primitive). assert.strictEqual(output.phase_number, 2); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( roadmap.includes(`### Phase 2: ${goalShaped}`), 'phase should still be created verbatim despite the warning' ); assert.ok( fs.existsSync(path.join(tmpDir, output.directory)), 'phase directory should still be created' ); }); test('phase add title-length boundary: 79/80/81 chars (regression #2390)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const underLimit = 'A'.repeat(79); // limit - 1 const atLimit = 'A'.repeat(80); // limit const overLimit = 'A'.repeat(81); // limit + 1 const underResult = runGsdTools(['phase', 'add', underLimit], tmpDir); assert.ok(underResult.success, `Command failed: ${underResult.error}`); assert.ok( !('warning' in JSON.parse(underResult.output)), '79 chars (limit-1) should not warn' ); const atResult = runGsdTools(['phase', 'add', atLimit], tmpDir); assert.ok(atResult.success, `Command failed: ${atResult.error}`); assert.ok(!('warning' in JSON.parse(atResult.output)), '80 chars (limit) should not warn'); const overResult = runGsdTools(['phase', 'add', overLimit], tmpDir); assert.ok(overResult.success, `Command failed: ${overResult.error}`); const overOutput = JSON.parse(overResult.output); assert.ok( typeof overOutput.warning === 'string' && overOutput.warning.length > 0, '81 chars (limit+1) should warn' ); }); test('phase add rejects an empty description (unaffected by the #2390 warning heuristic)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` ); const result = runGsdTools(['phase', 'add', ''], tmpDir); assert.ok(!result.success, 'empty description should still fail'); assert.match(result.error, /description required/); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase add — orphan directory collision prevention (#2026) // ───────────────────────────────────────────────────────────────────────────── describe('#3163: phase add inserts in the active milestone phase list, not the trailing archive', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); // Fixture: a v1.0 ACTIVE milestone with a phase list, followed by a shipped // v0.9 archive whose own `---` is the FILE's last `---`. The bug places the // new phase before that archive `---`; the fix scopes to v1.0's window. function writeArchiveRoadmap(dir, { singlePhase = false } = {}) { fs.writeFileSync( path.join(dir, '.planning', 'STATE.md'), 'milestone: v1.0\ncurrent_phase: 2\n' ); const phases = singlePhase ? '### Phase 1: Foundation\n**Goal:** setup\n' : '### Phase 1: Foundation\n**Goal:** setup\n\n### Phase 2: API\n**Goal:** build\n'; fs.writeFileSync( path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## v1.0: Active Milestone\n\n' + phases + '\n---\n\n## v0.9: Shipped Archive\n\n### Phase 0 (original scope): Bootstrap\n**Goal:** init\n\n#### Operator decisions\n- decided X\n\n---\n' ); } test('row 1 — phase add lands in the active milestone, before the archive', () => { writeArchiveRoadmap(tmpDir); const result = runGsdTools('phase add New Feature', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const phase3 = roadmap.indexOf('### Phase 3: New Feature'); const phase2 = roadmap.indexOf('### Phase 2: API'); const archive = roadmap.indexOf('## v0.9: Shipped Archive'); assert.notStrictEqual(phase3, -1, 'new phase entry should exist in the roadmap'); assert.ok(phase2 > -1 && phase2 < phase3, `Phase 3 must come after Phase 2; got p2@${phase2} p3@${phase3}`); assert.ok( phase3 < archive, `#3163: Phase 3 must land INSIDE the active v1.0 milestone (before the v0.9 archive), not before the file's last \`---\`; got phase3@${phase3} archive@${archive}` ); }); test('row 2 — phase add-batch is also scoped to the active milestone', () => { writeArchiveRoadmap(tmpDir); const result = runGsdTools( ['phase', 'add-batch', '--descriptions', '["First Add","Second Add"]'], tmpDir ); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const archive = roadmap.indexOf('## v0.9: Shipped Archive'); for (const [num, title] of [['3', 'First Add'], ['4', 'Second Add']]) { const at = roadmap.indexOf(`### Phase ${num}: ${title}`); assert.notStrictEqual(at, -1, `Phase ${num} (${title}) should exist`); assert.ok( at < archive, `#3163: Phase ${num} must land before the archive; got @${at} archive@${archive}` ); } }); test('row 3 — no-milestone fallback keeps legacy insertion before the trailing ---', () => { // No STATE.md milestone field and no WIP marker → currentMilestoneRawRanges // returns null → the legacy whole-file lastIndexOf('\n---') path is used. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Foundation\n**Goal:** setup\n\n---\n' ); const result = runGsdTools('phase add Next Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const phase2 = roadmap.indexOf('### Phase 2: Next Phase'); const sep = roadmap.indexOf('\n---'); assert.notStrictEqual(phase2, -1, 'Phase 2 should exist'); assert.ok( phase2 < sep, `no-milestone fallback must preserve legacy placement (before the trailing ---); got phase2@${phase2} sep@${sep}` ); }); test('row 4 — single-phase milestone still scopes to the active window', () => { writeArchiveRoadmap(tmpDir, { singlePhase: true }); const result = runGsdTools('phase add Second Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const phase2 = roadmap.indexOf('### Phase 2: Second Phase'); const archive = roadmap.indexOf('## v0.9: Shipped Archive'); assert.notStrictEqual(phase2, -1, 'Phase 2 should exist'); assert.ok( phase2 < archive, `Phase 2 must land inside the single-phase v1.0 window, before the archive; got @${phase2} archive@${archive}` ); }); }); describe('phase add — orphan directory collision prevention (#2026)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('orphan directory with higher number than ROADMAP pushes maxPhase up', () => { // Orphan directory 05-orphan exists on disk but is NOT in ROADMAP.md const orphanDir = path.join(tmpDir, '.planning', 'phases', '05-orphan'); fs.mkdirSync(orphanDir, { recursive: true }); fs.writeFileSync(path.join(orphanDir, 'SUMMARY.md'), 'existing work'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '## Milestone v1', '### Phase 1: First phase', '**Plans:** 0 plans', '---', ].join('\n') ); const result = runGsdTools('phase add dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // ROADMAP max is 1, but orphan 05-orphan means disk max is 5 → new phase = 6 assert.strictEqual(output.phase_number, 6, 'should be phase 6 (orphan 05 pushes max to 5)'); // The new directory must be 06-dashboard, not 02-dashboard assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard')), 'new phase directory must be 06-dashboard, not collide with orphan 05-orphan' ); // The orphan directory must be untouched assert.ok( fs.existsSync(path.join(orphanDir, 'SUMMARY.md')), 'orphan directory content must be preserved (not overwritten)' ); }); test('orphan directories with 999.x prefix are skipped when calculating disk max', () => { // 999.x backlog orphans must not inflate the next sequential phase number const backlogOrphan = path.join(tmpDir, '.planning', 'phases', '999-backlog-stuff'); fs.mkdirSync(backlogOrphan, { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '### Phase 1: Foundation', '**Plans:** 0 plans', '---', ].join('\n') ); const result = runGsdTools('phase add new-feature', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // ROADMAP max is 1, disk orphan is 999 (backlog) → should be ignored → new phase = 2 assert.strictEqual(output.phase_number, 2, 'backlog 999.x orphan must not inflate phase count'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-new-feature')), 'new phase directory should be 02-new-feature' ); }); test('project_code prefix in orphan directory name is stripped before comparing', () => { // Orphan directory has project_code prefix e.g. CK-05-orphan const orphanDir = path.join(tmpDir, '.planning', 'phases', 'CK-05-old-feature'); fs.mkdirSync(orphanDir, { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: 'CK' }) ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '### Phase 1: Foundation', '**Plans:** 0 plans', '---', ].join('\n') ); const result = runGsdTools('phase add new-feature', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // ROADMAP max is 1, disk has CK-05-old-feature → strip prefix → disk max is 5 → new phase = 6 assert.strictEqual(output.phase_number, 6, 'project_code prefix must be stripped before disk max calculation'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-06-new-feature')), 'new phase directory must be CK-06-new-feature' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase add with project_code prefix // ───────────────────────────────────────────────────────────────────────────── describe('phase add with project_code', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('prefixes phase directory with project_code', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: 'CK' }) ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n' ); const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 2, 'should be phase 2'); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-user-dashboard')), 'directory should have CK- prefix' ); }); test('no prefix when project_code is null', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: null }) ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n' ); const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-user-dashboard')), 'directory should have no prefix' ); }); test('find-phase resolves prefixed directories', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 01', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'should find prefixed phase'); assert.strictEqual(output.phase_number, '01', 'should extract numeric phase number'); }); test('phases list sorts prefixed directories correctly', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-api'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-03-ui'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.directories, ['CK-01-foundation', 'CK-02-api', 'CK-03-ui'], 'prefixed phases should sort numerically' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // find-phase scalar counts (#3218, phase 8 of epic #3180 — ADR-3180 §7.5) // // Additive to `plans[]`/`summaries[]` (matrix A9): `plan_count`/`summary_count` // mirror `roadmap.analyze`'s naming for the LIVE set (status:superseded // excluded); `plan_count_all` is the PHYSICAL set — every canonically-named // plan file on disk, status:superseded included — named to echo // `scanPhasePlans`'s own `allPlanFiles` field so a reader can trace it back to // its source. See 40-design.md's "Per-site set" table and Amendment 1. // ───────────────────────────────────────────────────────────────────────────── describe('find-phase scalar counts (#3218)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); function writePlan(phaseDir, n, { superseded = false } = {}) { const body = superseded ? ['---', 'status: superseded', '---', '', '# Plan (retired)', ''].join('\n') : ['# Plan', ''].join('\n'); fs.writeFileSync(path.join(phaseDir, `03-${n}-PLAN.md`), body); } function writeSummary(phaseDir, n) { fs.writeFileSync(path.join(phaseDir, `03-${n}-SUMMARY.md`), '# Summary\n'); } test('A1: happy path — 3 plans, 2 summaries, none superseded: live 3 / 2, physical 3', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); writePlan(phaseDir, '01'); writePlan(phaseDir, '02'); writePlan(phaseDir, '03'); writeSummary(phaseDir, '01'); writeSummary(phaseDir, '02'); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plan_count, 3); assert.strictEqual(output.summary_count, 2); assert.strictEqual(output.plan_count_all, 3); }); test('A2 (#2349 case): all 3 plans status:superseded — live 0, physical 3', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); writePlan(phaseDir, '01', { superseded: true }); writePlan(phaseDir, '02', { superseded: true }); writePlan(phaseDir, '03', { superseded: true }); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // 0 live plans is a REAL answer here (every plan superseded), not "the // planner produced nothing" — that read is exactly what plan_count_all // exists to prevent at the sites that ask the disk-existence question. assert.strictEqual(output.plan_count, 0); assert.strictEqual(output.summary_count, 0); assert.strictEqual(output.plan_count_all, 3); }); test('A3: 1 of 3 superseded — live 2, physical 3', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); writePlan(phaseDir, '01'); writePlan(phaseDir, '02'); writePlan(phaseDir, '03', { superseded: true }); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plan_count, 2); assert.strictEqual(output.plan_count_all, 3); }); test('A6: zero plans — live 0, physical 0 (boundary)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.plan_count, 0); assert.strictEqual(output.summary_count, 0); assert.strictEqual(output.plan_count_all, 0); }); test('A7: phase directory absent — defined verdict, no crash, counts are null (not a fabricated 0)', () => { const result = runGsdTools('find-phase 99', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false); // null, not 0 — a fabricated 0 here would read identically to "phase // exists with zero plans" (A2/A6), which is a real, distinct answer. assert.strictEqual(output.plan_count, null); assert.strictEqual(output.summary_count, null); assert.strictEqual(output.plan_count_all, null); }); // A8 (phase dir unreadable): NOT separately testable — cmdFindPhase's own // fs.readdirSync(phaseDir) throw is caught by the SAME per-searchDir // try/catch that produces the A7 not-found result (src/phase.cts, the loop // around scanPhasePlans), so an unreadable phase dir and a missing one are // indistinguishable at this seam and both land on the same `notFound` // object this A7 test already covers (counts null, not a fabricated 0 — // "surfaced, not silently 0"). Root-safe fs-failure injection would need // `mock.method` on `fs.readdirSync`, but that only affects the test's own // process, and `cmdFindPhase`'s output goes through `writeAllSync(1, ...)` — // this file's own `capturePhaseComplete` helper (above) documents why // intercepting fd 1 in-process is unsafe on the remote matrix, so this // command is only exercised via the real `runGsdTools` subprocess, which // cannot see an in-process fs mock. Same unreachable-path shape already // recorded for the #2648 plan-coverage gate's B1 case (see the NOTE above // `describe('phase complete plan-coverage gate (#2648)')`). test('A9 (regression): plans[]/summaries[] arrays are unchanged by the new scalars', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); writePlan(phaseDir, '01'); writeSummary(phaseDir, '01'); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.plans, ['03-01-PLAN.md']); assert.deepStrictEqual(output.summaries, ['03-01-SUMMARY.md']); }); test('A10: live and physical counts are separately addressable — distinct keys, both present', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); writePlan(phaseDir, '01', { superseded: true }); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok('plan_count' in output && 'plan_count_all' in output, 'both keys must be present'); assert.notStrictEqual(output.plan_count, output.plan_count_all, 'must actually differ in this fixture'); assert.strictEqual(output.plan_count, 0); assert.strictEqual(output.plan_count_all, 1); }); // ── B: parity with the other owners of the same question ────────────────── test('B1/B2: find-phase counts equal scanPhasePlans().planFiles/allPlanFiles length for the same phase', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); writePlan(phaseDir, '01'); writePlan(phaseDir, '02', { superseded: true }); const result = runGsdTools('find-phase 03', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const planScanMod = require('../gsd-core/bin/lib/plan-scan.cjs'); const scan = planScanMod.scanPhasePlans(phaseDir); assert.strictEqual(output.plan_count, scan.planFiles.length, 'B1: plan_count == planFiles.length'); assert.strictEqual(output.plan_count_all, scan.allPlanFiles.length, 'B2: plan_count_all == allPlanFiles.length'); }); test('B3: find-phase plan_count agrees with roadmap.analyze plan_count for the same phase', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), ['# Roadmap', '', '- [ ] Phase 3: API', '', '### Phase 3: API', '**Goal:** Build API', '', '---', ''].join('\n'), ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); writePlan(phaseDir, '01'); writePlan(phaseDir, '02'); const findResult = runGsdTools('find-phase 03', tmpDir); assert.ok(findResult.success, `find-phase failed: ${findResult.error}`); const findOutput = JSON.parse(findResult.output); const roadmapResult = runGsdTools('roadmap analyze', tmpDir); assert.ok(roadmapResult.success, `roadmap.analyze failed: ${roadmapResult.error}`); const roadmapOutput = JSON.parse(roadmapResult.output); const phase3 = roadmapOutput.phases.find((p) => String(p.number) === '3' || p.number === 3); assert.ok(phase3, 'roadmap.analyze must report phase 3'); assert.strictEqual(findOutput.plan_count, phase3.plan_count, 'find-phase and roadmap.analyze must agree'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase add-batch command (#2165) // ───────────────────────────────────────────────────────────────────────────── describe('phase add-batch command (#2165)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '**Goal:** Setup', '', '---', '', ].join('\n') ); }); afterEach(() => { cleanup(tmpDir); }); test('adds multiple phases with sequential numbers in a single call', () => { // Use array form to avoid shell quoting issues with JSON args const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Alpha","Beta","Gamma"]'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.count, 3, 'should report 3 phases added'); assert.strictEqual(output.phases[0].phase_number, 2); assert.strictEqual(output.phases[1].phase_number, 3); assert.strictEqual(output.phases[2].phase_number, 4); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-alpha')), '02-alpha dir must exist'); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-beta')), '03-beta dir must exist'); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-gamma')), '04-gamma dir must exist'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('### Phase 2: Alpha'), 'roadmap should include Phase 2'); assert.ok(roadmap.includes('### Phase 3: Beta'), 'roadmap should include Phase 3'); assert.ok(roadmap.includes('### Phase 4: Gamma'), 'roadmap should include Phase 4'); }); test('no duplicate phase numbers when multiple add-batch calls are made sequentially', () => { // Regression for #2165: parallel `phase add` invocations produced duplicates // because each read disk state before any write landed. add-batch serializes // the entire batch under a single lock so the next call sees the updated state. const r1 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-One-A","Wave-One-B"]'], tmpDir); assert.ok(r1.success, `First batch failed: ${r1.error}`); const r2 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-Two-A","Wave-Two-B"]'], tmpDir); assert.ok(r2.success, `Second batch failed: ${r2.error}`); const out1 = JSON.parse(r1.output); const out2 = JSON.parse(r2.output); const allNums = [...out1.phases, ...out2.phases].map(p => p.phase_number); const unique = new Set(allNums); assert.strictEqual(unique.size, allNums.length, `Duplicate phase numbers detected: ${allNums}`); // Directories must all exist and be unique const dirs = fs.readdirSync(path.join(tmpDir, '.planning', 'phases')); assert.strictEqual(dirs.length, 4, `Expected 4 phase dirs, got: ${dirs}`); }); test('each phase directory contains a .gitkeep file', () => { const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Setup","Build"]'], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-setup', '.gitkeep')), '.gitkeep must exist in 02-setup' ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-build', '.gitkeep')), '.gitkeep must exist in 03-build' ); }); test('returns error for empty descriptions array', () => { const result = runGsdTools(['phase', 'add-batch', '--descriptions', '[]'], tmpDir); assert.ok(!result.success, 'should fail on empty array'); }); test('returns error when --descriptions JSON is not an array', () => { const result = runGsdTools(['phase', 'add-batch', '--descriptions', '{"one":"Alpha"}'], tmpDir); assert.ok(!result.success, 'should fail on non-array JSON'); assert.match(result.error, /--descriptions must be a JSON array/); }); test('returns error when --descriptions is missing its JSON value', () => { const missing = runGsdTools(['phase', 'add-batch', '--descriptions'], tmpDir); assert.ok(!missing.success, 'should fail on dangling --descriptions'); assert.match(missing.error, /--descriptions must be a JSON array/); const flagValue = runGsdTools(['phase', 'add-batch', '--descriptions', '--raw'], tmpDir); assert.ok(!flagValue.success, 'should fail when --descriptions value is another flag'); assert.match(flagValue.error, /--descriptions must be a JSON array/); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase insert command // ───────────────────────────────────────────────────────────────────────────── describe('phase insert command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('inserts decimal phase after target', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, '01.1', 'should be 01.1'); assert.strictEqual(output.after_phase, '1'); // Verify directory assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01.1-fix-critical-bug')), 'decimal phase directory should be created' ); // Verify ROADMAP const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('Phase 01.1: Fix Critical Bug (INSERTED)'), 'roadmap should include inserted phase'); }); test('increments decimal when siblings exist', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: API **Goal:** Build API ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01.1-hotfix'), { recursive: true }); const result = runGsdTools('phase insert 1 Another Fix', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, '01.2', 'should be 01.2'); }); test('rejects missing phase', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: Test\n**Goal:** Test\n` ); const result = runGsdTools('phase insert 99 Fix Something', tmpDir); assert.ok(!result.success, 'should fail for missing phase'); assert.ok(result.error.includes('not found'), 'error mentions not found'); }); test('handles padding mismatch between input and roadmap', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ## Phase 09.05: Existing Decimal Phase **Goal:** Test padding ## Phase 09.1: Next Phase **Goal:** Test ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '09.05-existing'), { recursive: true }); // Pass unpadded "9.05" but roadmap has "09.05" const result = runGsdTools('phase insert 9.05 Padding Test', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.after_phase, '9.05'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('(INSERTED)'), 'roadmap should include inserted phase'); }); test('phase insert includes **Requirements**: TBD in new ROADMAP entry', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('**Requirements**: TBD'), 'inserted phase entry should include Requirements TBD'); }); test('reports actionable error for summary-only placeholder phase without detail section (#3098)', () => { // #3098: a hybrid ROADMAP that has heading-style phases for some phases // but only a bullet summary entry for phase 5 (the detail section is // missing). Insert must fail with "missing a detail section" rather than // silently inserting in bullet-style — because the surrounding ROADMAP // uses headings, so the absent `### Phase 5:` is a genuine omission. // (Compare with the #3815 case below: a purely bullet-style ROADMAP that // has NO heading-style phases at all is valid and insert should succeed.) fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 4: Foundation\n**Goal:** Setup\n\n- [ ] **Phase 5: Placeholder**\n` ); const result = runGsdTools('phase insert 5 Hotfix', tmpDir); assert.ok(!result.success, 'should fail when phase is summary-only placeholder in a heading-style ROADMAP'); assert.ok(result.error.includes('missing a detail section')); }); test('phase insert rejects unsupported --dry-run flag explicitly (#3098)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n` ); const result = runGsdTools('phase insert 1 Hotfix --dry-run', tmpDir); assert.ok(!result.success, 'phase insert should reject unsupported --dry-run'); assert.ok(result.error.includes('does not support --dry-run')); }); test('handles #### heading depth from multi-milestone roadmaps', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### v1.1 Milestone #### Phase 5: Feature Work **Goal:** Build features #### Phase 6: Polish **Goal:** Polish ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-feature-work'), { recursive: true }); const result = runGsdTools('phase insert 5 Hotfix', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, '05.1'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('Phase 05.1: Hotfix (INSERTED)'), 'roadmap should include inserted phase'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase remove command // ───────────────────────────────────────────────────────────────────────────── describe('phase remove command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('removes phase directory and renumbers subsequent', () => { // Setup 3 phases fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup **Depends on:** Nothing ### Phase 2: Auth **Goal:** Authentication **Depends on:** Phase 1 ### Phase 3: Features **Goal:** Core features **Depends on:** Phase 2 ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); const p2 = path.join(tmpDir, '.planning', 'phases', '02-auth'); fs.mkdirSync(p2, { recursive: true }); fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); const p3 = path.join(tmpDir, '.planning', 'phases', '03-features'); fs.mkdirSync(p3, { recursive: true }); fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p3, '03-02-PLAN.md'), '# Plan 2'); // Remove phase 2 const result = runGsdTools('phase remove 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.removed, '2'); assert.strictEqual(output.directory_deleted, '02-auth'); // Phase 3 should be renumbered to 02 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features')), 'phase 3 should be renumbered to 02-features' ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-features')), 'old 03-features should not exist' ); // Files inside should be renamed assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-01-PLAN.md')), 'plan file should be renumbered to 02-01' ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-02-PLAN.md')), 'plan 2 should be renumbered to 02-02' ); // ROADMAP should be updated const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('Phase 2: Auth'), 'removed phase should not be in roadmap'); assert.ok(roadmap.includes('Phase 2: Features'), 'phase 3 should be renumbered to 2'); }); // WARNING-3 (#3511 review): renameIntegerPhases renames a phase directory // whose leading number is UNPADDED (dir regex accepts bare `\d+`), but // renamed its artifact files only against the 2-PADDED prefix, so an // unpadded-numbered artifact desynced from its now-renamed directory and // the phase read `missing` afterward. test('#3511 WARNING-3: renames an unpadded-numbered artifact alongside its unpadded dir', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 9: B\n**Goal:** B\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); const p9 = path.join(tmpDir, '.planning', 'phases', '9-slug'); fs.mkdirSync(p9, { recursive: true }); // Unpadded artifact filename, paired with the unpadded dir number. fs.writeFileSync(path.join(p9, '9-VERIFICATION.md'), '---\nstatus: passed\n---\n\nVerified OK.'); const result = runGsdTools('phase remove 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newDir = path.join(tmpDir, '.planning', 'phases', '08-slug'); assert.ok(fs.existsSync(newDir), 'phase 9 should be renumbered to 08-slug'); assert.ok( fs.existsSync(path.join(newDir, '08-VERIFICATION.md')), 'the unpadded 9-VERIFICATION.md should be renamed to 08-VERIFICATION.md alongside the dir' ); assert.ok( !fs.existsSync(path.join(newDir, '9-VERIFICATION.md')), 'the stale unpadded-prefix file should no longer exist' ); const findResult = runGsdTools('find-phase 8', tmpDir); assert.ok(findResult.success, `find-phase failed: ${findResult.error}`); const findOutput = JSON.parse(findResult.output); assert.strictEqual(findOutput.found, true, 'renumbered phase 8 should resolve'); }); // #3511 BLOCKER-2 follow-up (security-review regression): the collision // guard added to stop the original overwrite-and-lose-data bug (BLOCKER-1) // introduced a NEW wrong-answer regression — skipping the rename let a // STRAY cross-phase file outrank the phase's own report at the canonical // name. Phase 9's directory holds its OWN `09-VERIFICATION.md` (gaps_found) // and a stray `08-VERIFICATION.md` (passed) that actually belongs to phase // 8. Removing phase 8 renumbers phase 9 -> phase 8 and must displace the // stray (never overwrite it, never let it win) so the phase's own report // lands at the canonical name. test('#3511 BLOCKER-2 follow-up: collision displaces the occupying file instead of skip-or-overwrite', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 8: A\n**Goal:** A\n### Phase 9: B\n**Goal:** B\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '08-a'), { recursive: true }); const p9 = path.join(tmpDir, '.planning', 'phases', '09-foo'); fs.mkdirSync(p9, { recursive: true }); // The phase's OWN report. fs.writeFileSync( path.join(p9, '09-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n\nOwn report for phase 9.' ); // A STRAY report belonging to phase 8, sitting inside phase 9's directory. fs.writeFileSync( path.join(p9, '08-VERIFICATION.md'), '---\nstatus: passed\n---\n\nStray report belonging to phase 8.' ); const result = runGsdTools('phase remove 8', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); const newDir = path.join(tmpDir, '.planning', 'phases', '08-foo'); assert.ok(fs.existsSync(newDir), 'phase 9 should be renumbered to 08-foo'); // (a) no file is lost — both original contents still exist on disk. const canonicalPath = path.join(newDir, '08-VERIFICATION.md'); const displacedPath = path.join(newDir, '08-VERIFICATION.md.orphaned'); assert.ok(fs.existsSync(canonicalPath), 'canonical 08-VERIFICATION.md must exist'); assert.ok(fs.existsSync(displacedPath), 'the displaced stray file must still exist on disk'); const canonical = fs.readFileSync(canonicalPath, 'utf-8'); const displaced = fs.readFileSync(displacedPath, 'utf-8'); assert.ok( displaced.includes('Stray report belonging to phase 8'), 'displaced file must retain the stray content' ); // (b) 08-VERIFICATION.md in the renamed dir is the phase's OWN former // 09-VERIFICATION.md — assert on its body/status, not just its name. assert.ok( canonical.includes('status: gaps_found'), `canonical 08-VERIFICATION.md must be the phase's own report (gaps_found), ` + `not the stray (passed). Got: ${canonical}` ); assert.ok( canonical.includes('Own report for phase 9'), 'canonical file body must be the phase\'s own former 09-VERIFICATION.md content' ); assert.ok( !fs.existsSync(path.join(newDir, '09-VERIFICATION.md')), 'the stale 09-VERIFICATION.md name should no longer exist' ); // (c) the displacement is reported in the command output. assert.ok( Array.isArray(output.renamed_file_collisions), 'renamed_file_collisions must be present in the output' ); const entry = output.renamed_file_collisions.find((c) => c.to === '08-VERIFICATION.md'); assert.ok( entry, `expected a collision entry for 08-VERIFICATION.md, got: ${JSON.stringify(output.renamed_file_collisions)}` ); assert.strictEqual(entry.from, '09-VERIFICATION.md'); assert.strictEqual(entry.displaced_to, '08-VERIFICATION.md.orphaned'); }); test('rejects removal of phase with summaries unless --force', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: Test\n**Goal:** Test\n` ); // Should fail without --force const result = runGsdTools('phase remove 1', tmpDir); assert.ok(!result.success, 'should fail without --force'); assert.ok(result.error.includes('executed plan'), 'error mentions executed plans'); // Should succeed with --force const forceResult = runGsdTools('phase remove 1 --force', tmpDir); assert.ok(forceResult.success, `Force remove failed: ${forceResult.error}`); }); test('bug-3409: supports --force before phase id', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); const result = runGsdTools('phase remove --force 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.removed, '2'); assert.strictEqual(output.directory_deleted, '02-b'); assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-b'))); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented after real removal'); }); test('removes decimal phase and renumbers siblings', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 6: Main\n**Goal:** Main\n### Phase 6.1: Fix A\n**Goal:** Fix A\n### Phase 6.2: Fix B\n**Goal:** Fix B\n### Phase 6.3: Fix C\n**Goal:** Fix C\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-main'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-fix-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-b'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c'), { recursive: true }); const result = runGsdTools('phase remove 6.2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // 06.3 should become 06.2 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-c')), '06.3 should be renumbered to 06.2' ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c')), 'old 06.3 should not exist' ); }); test('updates STATE.md phase count', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); runGsdTools('phase remove 2', tmpDir); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented'); }); test('bug-2434: integer phase remove does not rename 999.x backlog directory', () => { // Setup: an active integer phase 4 and a backlog phase 999.1 fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: Auth **Goal:** Authentication ### Phase 3: Features **Goal:** Core features ### Phase 4: Extras **Goal:** Extra stuff ### Phase 999.1: Backlog item **Goal:** Parked backlog task ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true }); const result = runGsdTools('phase remove 4', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // Backlog directory must remain at 999.1, not be decremented to 998.1 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')), 'backlog directory 999.1-backlog-item must not be renamed' ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '998.1-backlog-item')), 'backlog directory must not be incorrectly renamed to 998.1' ); }); test('bug-16: integer phase remove renumbers canonical phases above 999 while preserving 999.x backlog', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1199: Baseline **Goal:** Before removal Plans: - [x] 1199-01-PLAN.md ### Phase 1200: Remove Me **Goal:** Target phase Plans: - [ ] 1200-01-PLAN.md ### Phase 1201: Follow Up A **Goal:** First phase after target **Depends on:** Phase 1200 Plans: - [ ] 1201-01-PLAN.md ### Phase 1202: Follow Up B **Goal:** Second phase after target **Depends on:** Phase 1201 Plans: - [ ] 1202-01-PLAN.md ### Phase 999.1: Backlog Item **Goal:** Parked backlog item ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1199-baseline'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1200-remove-me'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true }); const result = runGsdTools('phase remove 1200', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // On-disk phase directories should be decremented by one above removedInt. assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1200-follow-up-a')), '1201-follow-up-a should be renamed to 1200-follow-up-a', ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-b')), '1202-follow-up-b should be renamed to 1201-follow-up-b', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a')), 'old 1201-follow-up-a directory should not remain', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b')), 'old 1202-follow-up-b directory should not remain', ); // Backlog 999.x must remain untouched. assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')), 'backlog directory 999.1-backlog-item must not be renamed', ); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('### Phase 1200: Remove Me'), 'removed phase 1200 section must be gone'); assert.ok(roadmap.includes('### Phase 1200: Follow Up A'), 'phase 1201 should be renumbered to 1200'); assert.ok(roadmap.includes('### Phase 1201: Follow Up B'), 'phase 1202 should be renumbered to 1201'); assert.ok(!roadmap.includes('### Phase 1202: Follow Up B'), 'old phase 1202 heading must not remain'); assert.ok(roadmap.includes('**Depends on:** Phase 1200'), 'depends-on reference above removed phase should be decremented'); assert.ok(roadmap.includes('### Phase 999.1: Backlog Item'), 'backlog phase 999.1 heading must not be renumbered'); }); test('bug-2435: integer phase remove does not corrupt YYYY-MM-DD dates in ROADMAP.md', () => { // Setup: removing phase 4 from a roadmap containing 2026-04-14 date strings fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup **Completed:** 2026-01-15 ### Phase 2: Auth **Goal:** Authentication **Completed:** 2026-02-20 ### Phase 3: Features **Goal:** Core features **Completed:** 2026-04-14 ### Phase 4: Extras **Goal:** Extra stuff ### Phase 5: Final **Goal:** Final phase ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true }); const result = runGsdTools('phase remove 4', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // Dates must be preserved exactly assert.ok(roadmap.includes('2026-01-15'), 'date 2026-01-15 must not be corrupted'); assert.ok(roadmap.includes('2026-02-20'), 'date 2026-02-20 must not be corrupted'); assert.ok(roadmap.includes('2026-04-14'), 'date 2026-04-14 must not be corrupted'); // Phase 5 should be renumbered to 4 assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4'); }); test('bug-2435: integer phase remove does not corrupt date whose month matches removed phase number', () => { // Setup: removing phase 4 from a roadmap containing 2026-05-14 // When renumbering phase 5→4, the regex must not replace "05-14" in the date "2026-05-14" fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup **Completed:** 2026-01-15 ### Phase 2: Auth **Goal:** Authentication **Completed:** 2026-02-20 ### Phase 3: Features **Goal:** Core features **Completed:** 2026-03-10 ### Phase 4: Extras **Goal:** Extra stuff ### Phase 5: Final **Goal:** Final phase **Due:** 2026-05-14 ` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true }); const result = runGsdTools('phase remove 4', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // Date "2026-05-14" must not be corrupted to "2026-04-14" when phase 5 is renumbered to 4 assert.ok(roadmap.includes('2026-05-14'), 'date 2026-05-14 must not be corrupted when renumbering phase 5→4'); assert.ok(!roadmap.includes('2026-04-14'), 'date must not be incorrectly mutated to 2026-04-14'); // Phase 5 should be renumbered to 4 assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4'); }); test('bug-3355: integer phase remove renumbers roadmap once without collapsing later phases', () => { const lines = ['# Roadmap', '', '## Progress', '', '| Phase | Plans | Status | Notes |', '|---|---:|---|---|']; for (let n = 26; n <= 35; n++) { lines.push(`| ${n}. Phase ${n} | 0/1 | Planned | - |`); } lines.push(''); for (let n = 26; n <= 35; n++) { lines.push(`### Phase ${n}: Phase ${n}`); lines.push(`#### Phase ${n}.1: Phase ${n}.1 follow-up`); lines.push(`**Goal:** Build phase ${n}`); lines.push(n % 2 === 0 ? `**Depends on**: Phase ${n - 1}` : `**Depends on:** Phase ${n - 1}`); lines.push(`Plans: ${String(n).padStart(2, '0')}-01-PLAN.md`); lines.push(''); const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n}`); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, `${String(n).padStart(2, '0')}-01-PLAN.md`), '# Plan'); } fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), lines.join('\n')); const result = runGsdTools('phase remove 27 --force', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.equal((roadmap.match(/\|\s*27\.\s/g) || []).length, 1, 'progress row 27 appears once'); assert.equal((roadmap.match(/\|\s*28\.\s/g) || []).length, 1, 'progress row 28 appears once'); assert.equal((roadmap.match(/\|\s*34\.\s/g) || []).length, 1, 'progress row 34 appears once'); assert.equal((roadmap.match(/\|\s*35\.\s/g) || []).length, 0, 'old progress row 35 removed by renumber'); assert.equal((roadmap.match(/^### Phase 27:/gm) || []).length, 1, 'heading 27 appears once'); assert.equal((roadmap.match(/^### Phase 34:/gm) || []).length, 1, 'heading 34 appears once'); assert.equal((roadmap.match(/^### Phase 35:/gm) || []).length, 0, 'old heading 35 removed by renumber'); assert.equal((roadmap.match(/^#### Phase 27\.1:/gm) || []).length, 1, 'decimal heading 27.1 appears once'); assert.equal((roadmap.match(/^#### Phase 34\.1:/gm) || []).length, 1, 'decimal heading 34.1 appears once'); assert.equal((roadmap.match(/^#### Phase 35\.1:/gm) || []).length, 0, 'old decimal heading 35.1 removed by renumber'); assert.equal((roadmap.match(/\*\*Depends on\*\*:\s*Phase\s+28\b/g) || []).length, 1, 'bold depends-on with outside colon is decremented'); assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+29\b/g) || []).length, 1, 'legacy bold depends-on with inside colon is decremented'); assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+35\b/g) || []).length, 0, 'old depends-on 35 removed by renumber'); assert.equal((roadmap.match(/\b27-01-PLAN\.md\b/g) || []).length, 1, 'plan id 27-01 appears once'); assert.equal((roadmap.match(/\b34-01-PLAN\.md\b/g) || []).length, 1, 'plan id 34-01 appears once'); assert.equal((roadmap.match(/\b35-01-PLAN\.md\b/g) || []).length, 0, 'old plan id 35-01 removed by renumber'); for (let n = 27; n <= 34; n++) { assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n + 1}`)), `phase directory ${n} should preserve original phase slug ${n + 1}`, ); } }); test('#2245 F3: Progress-ordinal renumber re-escapes an escaped pipe in the Phase cell', () => { // The renumber's `newValue` callback builds its replacement from the // CURRENT (unescaped) cell value and used to splice it back verbatim — // an escaped `\|` in the cell de-escapes to a literal `|` and splits the // cell into an extra column when spliced back unescaped. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foo - [ ] Phase 2: Bar - [ ] Phase 3: Parser | Lexer ## Progress | Phase | Plans Complete | Status | Completed | |-------|-----------------|--------|-----------| | 1. Foo | 0/1 | Pending | - | | 2. Bar | 0/1 | Pending | - | | 3. Parser \\| Lexer | 0/1 | Pending | - | `, ); const result = runGsdTools('phase remove 2 --force', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( roadmap.includes('| 2. Parser \\| Lexer | 0/1 | Pending | - |'), `the escaped pipe must survive the renumber intact:\n${roadmap}`, ); // Reject the de-escaped, cell-splitting shape. assert.ok( !/\|\s*2\. Parser \|\s*Lexer\s*\|\s*0\/1\s*\|/.test(roadmap), 'the row must not have split into an extra column', ); }); test('#2245 F8: Progress-ordinal renumber padding-recovery is keyed by row index, not trimmed value', () => { // Two rows with identical TRIMMED Phase text but different padding: an // already-rewritten row's NEW value can coincide with a still-unprocessed // row's PRE-edit value, so a content-keyed padding lookup can steal the // wrong (already-rewritten) row's padding. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Anchor ## Progress | Phase | Plans Complete | Status | Completed | |-------|-----------------|--------|-----------| | 4. Foo | 0/1 | Pending | - | | 3. Foo | 0/1 | Pending | - | `, ); const result = runGsdTools('phase remove 1 --force', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( roadmap.includes('| 3. Foo | 0/1 | Pending | - |'), `row 4->3 must keep its OWN (3-space) padding:\n${roadmap}`, ); assert.ok( roadmap.includes('| 2. Foo | 0/1 | Pending | - |'), `row 3->2 must keep its OWN (1-space) padding, not borrow row 4->3's:\n${roadmap}`, ); }); test('#2143 audit: removing the LAST phase preserves a trailing ## Progress section and its table', () => { // Root cause: updateRoadmapAfterPhaseRemoval's whole-section delete used to // be a raw greedy regex whose lookahead only recognised ANOTHER "Phase N:" // heading as a stop boundary. Removing the LAST phase left no such heading // to stop at, so the lazy [\s\S]*? scan ran to EOF and swept away // everything after it — including a trailing `## Progress` heading and its // tracking table (data loss). Fixed by migrating the delete onto // deleteSection (markdown-sectionizer.cjs), whose level-bounded stop halts // at the next heading of the SAME-OR-HIGHER level regardless of its text. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: Auth **Goal:** Authentication ## Progress | Phase | Plans | Status | Completed | |---|---|---|---| | 1 | 0/1 | Planned | - | | 2 | 0/1 | Planned | - | `, ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); const result = runGsdTools('phase remove 2 --force', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('Phase 2: Auth'), 'removed Phase 2 section must be gone'); assert.ok(!roadmap.includes('Authentication'), 'removed Phase 2 body content must be gone'); assert.ok(roadmap.includes('### Phase 1: Foundation'), 'Phase 1 (untouched) must survive'); assert.ok( roadmap.includes('## Progress'), 'trailing ## Progress heading must survive removing the LAST phase — the #2143 audit data-loss bug', ); assert.ok( roadmap.includes('| Phase | Plans | Status | Completed |'), 'Progress table header must survive', ); assert.ok( roadmap.includes('| 1 | 0/1 | Planned | - |'), "Phase 1's own progress row must survive", ); }); test('#2245 audit: COMPACT unpadded Progress row for the removed phase is deleted (deleteTableRow migration)', () => { // Root cause: the prior ROW-DELETION regex was // `\|\s*${escaped}\.?\s[^|]*\|` — the `\.?\s` required a WHITESPACE // character immediately after the phase number. A fully compact, unpadded // row (no spaces around any pipe, e.g. `|2|0/2|Planned|-|`) has the // closing `|` immediately after the digit — no whitespace to match — so // the row was never recognised and the removed phase's stale row was left // behind. Migrated onto deleteTableRow (ADR-2143 §7), which matches the // row by its FIRST cell's value regardless of padding. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: Beta **Goal:** Something ### Phase 3: Features **Goal:** Core features ## Progress |Phase|Plans Complete|Status|Completed| |---|---|---|---| |1|0/1|Planned|-| |2|0/2|Planned|-| |3|0/1|Planned|-| `, ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-beta'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); const result = runGsdTools('phase remove 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( !roadmap.includes('|2|0/2|Planned|-|'), 'the COMPACT phase-2 progress row must be deleted (it survived pre-fix)', ); assert.ok(roadmap.includes('|1|0/1|Planned|-|'), "phase 1's compact progress row must survive"); assert.ok( roadmap.includes('|3|0/1|Planned|-|'), "phase 3's compact progress row must survive (its own bare-digit renumbering is a separate, out-of-scope cross-phase-renumber concern)", ); }); test('#2245 audit: a PADDED Progress table deletes exactly the removed row, byte-parity on the rest', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 1: Foundation **Goal:** Setup ### Phase 2: Beta **Goal:** Something ### Phase 3: Features **Goal:** Core features ## Progress | Phase | Plans Complete | Status | Completed | |-------|-----------------|-------------|------------| | 1. Foundation | 2/2 | Complete | 2026-01-01 | | 2. Beta | 1/2 | In Progress | | | 3. Features | 0/2 | Planned | | `, ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-beta'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); const result = runGsdTools('phase remove 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(!roadmap.includes('2. Beta'), 'removed phase 2 progress row must be gone'); assert.ok( roadmap.includes('| Phase | Plans Complete | Status | Completed |'), 'Progress table header must be byte-identical', ); assert.ok( roadmap.includes('|-------|-----------------|-------------|------------|'), 'Progress table delimiter row must be byte-identical', ); assert.ok( roadmap.includes('| 1. Foundation | 2/2 | Complete | 2026-01-01 |'), "phase 1's row must be byte-identical (untouched)", ); // Phase 3 is renumbered to phase 2 (its heading and phases/ directory both // become "2" once phase 2 is removed) — the pre-existing renumber block // below (untouched by this migration) decrements its Progress-table // ordinal too, `3.` -> `2.`, so the surviving row's phase number tracks // its new identity consistently with the heading/directory; every OTHER // byte of the row (padding, Plans/Status/Completed cells) stays identical. assert.ok( !roadmap.includes('| 3. Features | 0/2 | Planned | |'), 'the stale phase-3 ordinal must not survive once renumbered to phase 2', ); assert.ok( roadmap.includes('| 2. Features | 0/2 | Planned | |'), "surviving row's Plans/Status/Completed cells stay byte-identical; only its leading ordinal renumbers 3->2", ); }); // ─── #2640: state_updated must reflect actual content change, and progress // frontmatter must be resync'd even when the body lacks 'Total Phases:'. ── test('#2640 — state_updated reflects actual content change (not just file existence)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: A\n**Goal:** x\n\n### Phase 2: B\n**Goal:** y\n`, ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); // STATE.md with 'Total Phases:' body field + progress frontmatter fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `---\ngsd_state_version: 1.0\ncurrent_phase: 1\nprogress:\n total_phases: 2\n completed_phases: 0\n percent: 0\n---\n\n# State\n\nTotal Phases: 2\n`, ); const result = runGsdTools('phase remove 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.state_updated, true, 'state_updated must be true when STATE.md content changed'); // Body 'Total Phases:' must be decremented from 2 to 1. const afterState = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const bodyMatch = afterState.match(/^Total Phases:\s*(\d+)/m); assert.ok(bodyMatch, 'body must have Total Phases field after remove'); assert.strictEqual(bodyMatch[1], '1', `body 'Total Phases:' must be 1 after removing one of 2 phases; got ${bodyMatch[1]}`); // Frontmatter progress.total_phases must agree. const fmMatch = afterState.match(/total_phases:\s*(\d+)/); assert.ok(fmMatch, 'frontmatter must have total_phases'); assert.strictEqual(fmMatch[1], '1', `frontmatter progress.total_phases must be 1; got ${fmMatch[1]}`); }); test('#2640 — progress.total_phases resync\'d even when body lacks Total Phases', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: A\n**Goal:** x\n\n### Phase 2: B\n**Goal:** y\n\n### Phase 3: C\n**Goal:** z\n`, ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); // STATE.md with NO 'Total Phases:' body field, but with progress frontmatter fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `---\ngsd_state_version: 1.0\ncurrent_phase: 1\nprogress:\n total_phases: 3\n completed_phases: 0\n percent: 0\n---\n\n# State\n\nNo body phase count here.\n`, ); const beforeState = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const beforeMatch = beforeState.match(/total_phases:\s*(\d+)/); assert.ok(beforeMatch && beforeMatch[1] === '3', 'precondition: total_phases should be 3'); const result = runGsdTools('phase remove 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const afterState = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const afterMatch = afterState.match(/total_phases:\s*(\d+)/); assert.ok(afterMatch, `STATE.md frontmatter must still have total_phases after remove; got:\n${afterState}`); // Must be exactly 2 — 3 phases minus 1 removed. Asserting the exact value // catches a wrong count (not just "not 3"). assert.strictEqual(afterMatch[1], '2', `total_phases must be exactly 2 after removing one of 3 phases; got ${afterMatch[1]}`); }); test('#2640 — state_updated is false when STATE.md does not exist', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: A\n**Goal:** x\n`, ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); // No STATE.md const result = runGsdTools('phase remove 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.state_updated, false, 'state_updated must be false when no STATE.md exists'); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase complete command // ───────────────────────────────────────────────────────────────────────────── describe('phase complete canonical verification gate (#1522)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); for (const [name, verificationStatus, expectedMessage] of [ ['missing verification report', null, /No verification report found/i], ['unknown verification status', 'unexpected_value', /Unexpected verification status/i], ['human-needed verification status', 'human_needed', /Human verification required/i], ['gap-bearing verification status', 'gaps_found', /Gaps found/i], ]) { test(`blocks ${name} before mutating ROADMAP or STATE`, () => { writePhaseCompleteVerificationGateFixture(tmpDir, verificationStatus); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8'); const beforeState = fs.readFileSync(statePath, 'utf-8'); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'phase complete must fail when verification has not passed'); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_verification_incomplete'); assert.match(errorPayload.message, expectedMessage); assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap); assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState); }); } test('allows passed verification to complete and advance the phase', () => { writePhaseCompleteVerificationGateFixture(tmpDir, 'passed'); const result = runGsdTools(['phase', 'complete', '1'], tmpDir); assert.equal(result.success, true, `phase complete failed: ${result.error}`); const output = JSON.parse(result.output); assert.equal(output.completed_phase, '1'); assert.equal(output.next_phase, '02'); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.match(roadmap, /- \[x\] Phase 1: Foundation/); assert.match(state, /\*\*Current Phase:\*\* 02/); }); test('blocks stale passed verification when summaries changed later', () => { writePhaseCompleteVerificationGateFixture(tmpDir, 'passed'); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-01-SUMMARY.md'); const verificationPath = path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-VERIFICATION.md'); const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8'); const beforeState = fs.readFileSync(statePath, 'utf-8'); const older = new Date('2025-01-01T00:00:00.000Z'); const newer = new Date('2025-01-01T00:01:00.000Z'); fs.utimesSync(verificationPath, older, older); fs.utimesSync(summaryPath, newer, newer); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'phase complete must fail when verification is stale'); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_verification_incomplete'); assert.match(errorPayload.message, /stale/i); // #2617: the blocked-completion message now projects next_command onto the // runtime's installed surface. This project has no runtime configured, so it // takes the `claude` default — the canonical `/gsd-` hyphen form. The colon // form this previously asserted is the deprecated shape #2617 removed. assert.match(errorPayload.message, /\/gsd-verify-work 0?1/); assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap); assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState); }); }); // #2648: phase.complete used to gate only on a single *-VERIFICATION.md status, // so a phase could close "complete" while an arbitrary number of its plans had // no completion record (confirmed production incident: 6/30 plans unexecuted, // including the phase's entire final UI scope, with every signal green). The // fix adds a fail-closed plan-coverage gate that refuses completion when any // non-retired plan lacks a *-SUMMARY.md, naming the missing plans. A plan // retired via machine-readable `status: superseded` frontmatter (#2349) is // excluded from the gate so the legitimate lock/recovery pattern still works. describe('phase complete plan-coverage gate (#2648)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); // Build a phase-1 directory with a configurable set of plan/summary files and // a passed VERIFICATION (so the ONLY thing that could block completion is the // plan-coverage gate — isolating it from the #1522 verification gate). The // ROADMAP declares Phase 1 so completion has a phase to act on. function writePlanCoverageFixture(tmpDir, { plans, summaries, supersededPlans = [] }) { const planningDir = path.join(tmpDir, '.planning'); const phase1Dir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phase1Dir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 1: Foundation', '', '### Phase 1: Foundation', '**Goal:** Setup', '**Plans:** 3 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/3 | Not started | - |', '', ].join('\n'), ); // createTempProject() scaffolds .planning/phases but NOT STATE.md; write it // so the "ROADMAP/STATE unchanged on refusal" assertions have a file to read // (mirrors writePhaseCompleteVerificationGateFixture's STATE.md write above). fs.writeFileSync( path.join(planningDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Current Plan:** 01-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working on phase 1', '', ].join('\n'), ); for (const plan of plans) { const isSuperseded = supersededPlans.includes(plan); const body = isSuperseded ? ['---', 'status: superseded', '---', '', '# Plan (retired)', ''].join('\n') : ['# Plan', ''].join('\n'); fs.writeFileSync(path.join(phase1Dir, `01-${plan}-PLAN.md`), body); } for (const summary of summaries) { fs.writeFileSync(path.join(phase1Dir, `01-${summary}-SUMMARY.md`), '# Summary\n'); } // A passed VERIFICATION — so the verification gate (#1522) does NOT fire; // only the plan-coverage gate is under test. fs.writeFileSync( path.join(phase1Dir, '01-VERIFICATION.md'), ['---', 'status: passed', '---', '', '# Verification', ''].join('\n'), ); } test('blocks completion when plans lack summaries despite a passed verification', () => { // 3 plans, only 1 has a summary → 2 unsummarized. Verifier passed. // Pre-#2648 this completed silently; it must now refuse and name the gaps. writePlanCoverageFixture(tmpDir, { plans: ['01', '02', '03'], summaries: ['01'], }); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const beforeRoadmap = fs.readFileSync(roadmapPath, 'utf-8'); const beforeState = fs.readFileSync(statePath, 'utf-8'); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'phase complete must fail when plans lack completion records (#2648)'); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_plan_coverage_incomplete'); assert.match(errorPayload.message, /2 plan\(s\) have no completion record/); // Names the missing plans (02 and 03), not a generic refusal. assert.match(errorPayload.message, /02/); assert.match(errorPayload.message, /03/); // Nothing mutated. assert.equal(fs.readFileSync(roadmapPath, 'utf-8'), beforeRoadmap); assert.equal(fs.readFileSync(statePath, 'utf-8'), beforeState); }); test('a plan retired via status: superseded does not block completion', () => { // 3 plans, 1 summary. Plans 02 and 03 have no summary, BUT plan 03 is // explicitly retired (status: superseded, #2349). Only plan 02 is an // actionable gap → completion still refuses, naming ONLY 02, proving the // retired plan is excluded from the gate (the lock/recovery pattern is // preserved, the Goodhart hole is closed). writePlanCoverageFixture(tmpDir, { plans: ['01', '02', '03'], summaries: ['01'], supersededPlans: ['03'], }); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'phase 2 is still an unsummarized, non-retired gap'); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_plan_coverage_incomplete'); assert.match(errorPayload.message, /1 plan\(s\) have no completion record/); assert.match(errorPayload.message, /02/); // The retired plan 03 is NOT named as a gap. assert.doesNotMatch(errorPayload.message, /\b03\b/); }); test('a fully-covered phase with a passed verification completes normally', () => { // Regression guard: the gate must not over-block a healthy phase where // every plan has a summary. writePlanCoverageFixture(tmpDir, { plans: ['01', '02', '03'], summaries: ['01', '02', '03'], }); const result = runGsdTools(['phase', 'complete', '1'], tmpDir); assert.equal(result.success, true, `phase complete failed on a fully-covered phase: ${result.error}`); const out = JSON.parse(result.output); assert.equal(out.completed_phase, '1'); }); // NOTE on the security-review B1 case (fail-closed on an UNREADABLE plan dir): // the gate defends it in code — it readdirSync's the phase dir and refuses on a // throw before scanPhasePlans' swallow-and-return-empty can make the gate pass // (src/phase.cts). It is NOT covered by a unit test here because there is no // cross-platform, root-safe way to construct it: any condition that makes the // phase dir unreadable to the gate's readdirSync ALSO makes findPhaseInternal // (which walks the parent phases/ dir) fail upstream with "Phase N not found" // before the gate runs, and the root-safe alternative to chmod 0o000 does not // exist (root bypasses mode bits, so a mode-based test silently passes with // zero coverage in root Docker/CI — the documented reason the repo forbids // chmod-based IO-failure tests). The defensive code is cheap and correct; the // unreachable-path gap is recorded in 60-review.json. }); describe('phase complete command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('marks phase complete and transitions to next', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation - [ ] Phase 2: API ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ### Phase 2: API **Goal:** Build API ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.completed_phase, '1'); assert.strictEqual(output.plans_executed, '1/1'); assert.strictEqual(output.next_phase, '02'); assert.strictEqual(output.is_last_phase, false); // Verify STATE.md updated const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('**Current Phase:** 02'), 'should advance to phase 02'); assert.ok(state.includes('**Status:** Ready to plan'), 'status should be ready to plan'); assert.ok(state.includes('**Current Plan:** Not started'), 'plan should be reset'); // Verify ROADMAP checkbox const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('[x]'), 'phase should be checked off'); assert.ok(roadmap.includes('completed'), 'completion date should be added'); }); // #2067: the checkbox regex in cmdPhaseComplete used a greedy `.*` between // `]` and `Phase N`, so completing Phase 1 (already checked → idempotent // re-run) matched a LATER phase whose description merely mentioned "Phase 1". // Non-global replace then checked the wrong phase's box. The gap between `]` // and `Phase` must allow only whitespace / markdown emphasis. test('#2067 — completing a phase must not check a later phase whose description mentions it (idempotent re-run)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [x] **Phase 1: Core Feed & Posting** - first slice (completed 2025-01-01) - [ ] **Phase 2: Polish & Edge Cases** - hardening (only as needed after Phase 1 verification) ### Phase 1: Core Feed & Posting **Goal:** Ship feed **Plans:** 1 plans ### Phase 2: Polish & Edge Cases **Goal:** Harden ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Core Feed & Posting\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-core-feed-posting'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-polish-edge-cases'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // Phase 2's checkbox MUST remain unchecked — its description mentions // "Phase 1" but it is NOT the phase being completed. const phase2Line = roadmap.match(/^- \[([ x])\] \*\*Phase 2:.*$/m); assert.ok(phase2Line, 'Phase 2 line must still be present in ROADMAP'); assert.strictEqual(phase2Line[1], ' ', 'Phase 2 checkbox must remain unchecked (#2067)'); // Phase 1 must remain checked. const phase1Line = roadmap.match(/^- \[([ x])\] \*\*Phase 1:.*$/m); assert.ok(phase1Line, 'Phase 1 line must still be present in ROADMAP'); assert.strictEqual(phase1Line[1], 'x', 'Phase 1 checkbox must remain checked (#2067)'); }); // #2067 companion: normal completion (Phase 1 unchecked) must still check // Phase 1 — and must NOT also check a later phase whose description mentions // Phase 1. Guards the tightened regex against over-restricting the happy path. test('#2067 — normal completion checks only the target phase when a later phase mentions it', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 1: Core Feed & Posting** - first slice - [ ] **Phase 2: Polish & Edge Cases** - hardening (only as needed after Phase 1 verification) ### Phase 1: Core Feed & Posting **Goal:** Ship feed **Plans:** 1 plans ### Phase 2: Polish & Edge Cases **Goal:** Harden ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Core Feed & Posting\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-core-feed-posting'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-polish-edge-cases'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const phase1Line = roadmap.match(/^- \[([ x])\] \*\*Phase 1:.*$/m); assert.ok(phase1Line, 'Phase 1 line must still be present in ROADMAP'); assert.strictEqual(phase1Line[1], 'x', 'Phase 1 checkbox must be checked'); const phase2Line = roadmap.match(/^- \[([ x])\] \*\*Phase 2:.*$/m); assert.ok(phase2Line, 'Phase 2 line must still be present in ROADMAP'); assert.strictEqual(phase2Line[1], ' ', 'Phase 2 checkbox must remain unchecked (#2067)'); }); test('#2012 — Progress row updated even when an earlier phase-numbered table precedes ## Progress', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 3: Build ### Phase 3: Build **Goal:** Build stuff ## Requirements Coverage | Phase | Requirements | Count | |-------|-------------|-------| | 3. Build | R-01 | 5 | ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 3. Build | v1.0 | 0/1 | Planned | - | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 03\n**Status:** In progress\n**Last Activity:** 2025-01-01\n` ); const p3 = path.join(tmpDir, '.planning', 'phases', '03-build'); fs.mkdirSync(p3, { recursive: true }); fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p3, '03-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // The Requirements coverage table row must be UNCHANGED (3 columns, not a Progress row). const reqRow = roadmap.match(/^\| 3\. Build \| R-01 \| 5 \|$/m); assert.ok(reqRow, 'Requirements coverage row must be untouched'); // The Progress row must be updated to Complete with a date. const progressRow = roadmap.match(/^\| 3\. Build \| v1\.0 \| 1\/1 \| Complete\s+\| \d{4}-\d{2}-\d{2} \|/m); assert.ok(progressRow, 'Progress row must be updated to Complete with a date'); }); test('detects last phase in milestone', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: Only Phase\n**Goal:** Everything\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-only-phase'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, true, 'should detect last phase'); assert.strictEqual(output.next_phase, null, 'no next phase'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('All phases complete'), 'status should be All phases complete'); }); // #1591: when the active milestone's phase checklist is wrapped in a //
block AND phases are written as `- [ ] Phase N:` checkbox list // items (not `### Phase N:` headings), phase.complete's next-phase enumerator // saw no further phases → is_last_phase=true, next_phase=null on a mid- // milestone phase, and STATE.md was wrongly marked "Milestone complete" with // total_phases decremented. extractCurrentMilestone correctly surfaces the //
-wrapped checklist; the defect was the heading-only phasePattern // at the isLastPhase enumerator not recognizing checkbox-list phase items. test('#1591:
-wrapped checkbox checklist — mid-milestone phase is NOT last', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# ROADMAP', '', '## Phases', '', '
', '✅ v1.0 First (Phases 1–3) — SHIPPED', '', '- [x] Phase 1: a', '- [x] Phase 2: b', '- [x] Phase 3: c', '', '
', '', '
', '🚀 v2.0 Second (Phases 36–38) — IN PLANNING', '', '- [x] Phase 36: first (completed)', '- [ ] Phase 37: second', '- [ ] Phase 38: third', '', '
', '', '## Backlog', '', '### Phase 999.1: future (BACKLOG)', '', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), [ '---', 'gsd_state_version: 1.0', 'milestone: v2.0', 'milestone_name: Second', 'current_phase: "36"', 'status: executing', '---', '', '# GSD State', '', '**Current Phase:** 36', '**Status:** Executing Phase 36', '', ].join('\n') ); // Only the COMPLETING phase (36) has a directory. Phases 37/38 exist only // as `- [ ]` checklist items in the ROADMAP — they are not yet started, so // they have no phase dirs. This is the @Azd325 scenario: the disk-based // next-phase resolver finds nothing, and the roadmap-enumeration fallback // (the heading-only phasePattern) is the only path that can find Phase 37. const d36 = path.join(tmpDir, '.planning', 'phases', '36-first'); fs.mkdirSync(d36, { recursive: true }); fs.writeFileSync(path.join(d36, '36-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(d36, '36-SUMMARY.md'), '# Summary\n'); const result = runVerifiedPhaseComplete('phase complete 36', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.is_last_phase, false, 'Phase 36 of 36–38 must NOT be last — Phases 37/38 are still open `- [ ]` (#1591)', ); assert.strictEqual( output.next_phase, '37', 'next_phase must resolve to 37 from the
-wrapped checkbox checklist (#1591)', ); // Cascade check: a wrong is_last_phase=true previously wrote "Milestone // complete" + decremented total_phases. With the fix, the milestone is // still in progress. const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok( !/Milestone complete|All phases complete/i.test(state), 'a mid-milestone phase must not flip STATE.md to milestone-complete (#1591)', ); }); // #1591 (bold-checklist follow-up): the roadmap TEMPLATE emits checklist rows // in the canonical BOLD form `- [ ] **Phase N: Name**`. The initial checkbox // broadening only matched the un-bolded `- [ ] Phase N:` shape, so the exact //
-wrapped bold template still fell through to is_last_phase=true. // Guard the canonical bold form explicitly. test('#1591:
-wrapped BOLD checkbox checklist — mid-milestone phase is NOT last', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# ROADMAP', '', '## Phases', '', '
', '🚀 v2.0 Second (Phases 36–38) — IN PLANNING', '', '- [x] **Phase 36: first (completed)** - done', '- [ ] **Phase 37: second** - one-line description', '- [ ] **Phase 38: third** - one-line description', '', '
', '', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), [ '---', 'gsd_state_version: 1.0', 'milestone: v2.0', 'milestone_name: Second', 'current_phase: "36"', 'status: executing', '---', '', '# GSD State', '', '**Current Phase:** 36', '**Status:** Executing Phase 36', '', ].join('\n') ); // Only Phase 36 has a directory; 37/38 are bold `- [ ]` checklist rows only. const d36 = path.join(tmpDir, '.planning', 'phases', '36-first'); fs.mkdirSync(d36, { recursive: true }); fs.writeFileSync(path.join(d36, '36-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(d36, '36-SUMMARY.md'), '# Summary\n'); const result = runVerifiedPhaseComplete('phase complete 36', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.is_last_phase, false, 'Phase 36 of 36–38 must NOT be last with the canonical BOLD checklist (#1591)', ); assert.strictEqual( output.next_phase, '37', 'next_phase must resolve to 37 from the BOLD `- [ ] **Phase N: ...**` checklist (#1591)', ); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok( !/Milestone complete|All phases complete/i.test(state), 'a mid-milestone phase must not flip STATE.md to milestone-complete — bold checklist (#1591)', ); }); // #1752: the #1591 follow-up — when phase.complete wrongly returned // is_last_phase=true on a
-wrapped mid-milestone checklist, the // milestone-complete cascade also DECREMENTED progress.total_phases (e.g. // 8 -> 7) and flipped status. Same root cause, distinct symptom. With the // #1591 fix (is_last_phase=false), the decrement must not occur: with all 8 // phase dirs on disk, total_phases stays 8 and status does not flip. test('#1752:
-wrapped checklist — total_phases is NOT decremented on a mid-milestone phase', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# ROADMAP', '', '## Phases', '', '
', '✅ v1.0 First (Phases 1–3) — SHIPPED', '', '- [x] Phase 1: a', '- [x] Phase 2: b', '- [x] Phase 3: c', '', '
', '', '
', '🚀 v2.0 Second (Phases 36–43) — IN PLANNING', '', '- [x] Phase 36: first (completed)', '- [ ] Phase 37: second', '- [ ] Phase 38: third', '- [ ] Phase 39: fourth', '', '
', '', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), [ '---', 'gsd_state_version: 1.0', 'milestone: v2.0', 'milestone_name: Second', 'current_phase: "36"', 'status: executing', 'progress:', ' total_phases: 8', ' completed_phases: 5', ' percent: 62', '---', '', '# GSD State', '', '**Current Phase:** 36', '**Status:** Executing Phase 36', '', ].join('\n') ); // All 8 phase dirs on disk (Phases 36–43) so the disk count is 8 — the // reporter's real state. Before the #1591 fix, phase.complete 36 returned // is_last_phase=true (no Phase 37+ heading match) and the milestone-complete // path DECREMENTED total_phases 8 -> 7. const names = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth']; for (let i = 0; i < 8; i++) { const num = String(36 + i); const d = path.join(tmpDir, '.planning', 'phases', `${num}-${names[i]}`); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, `${num}-PLAN.md`), '# Plan\n'); // #2648: phase.complete now refuses when a non-retired plan has no matching // *-SUMMARY.md. This test's concern is the total_phases-decrement cascade, // not plan coverage, so give each phase's plan a summary to keep it // fully-covered and isolate the #1752 behavior under test. fs.writeFileSync(path.join(d, `${num}-SUMMARY.md`), '# Summary\n'); } const result = runVerifiedPhaseComplete('phase complete 36', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, false, 'is_last_phase must be false (#1752 cascade root)'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok( !/Milestone complete|All phases complete/i.test(state), 'a mid-milestone phase must not flip STATE.md to milestone-complete (#1752)', ); const tpMatch = state.match(/total_phases:\s*(\d+)/); assert.ok(tpMatch, 'STATE.md must carry a total_phases value after phase.complete'); assert.notStrictEqual( parseInt(tpMatch[1], 10), 7, 'total_phases must NOT be decremented to 7 — the #1752 cascade of the false is_last_phase', ); }); test('updates REQUIREMENTS.md traceability when phase completes', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** AUTH-01, AUTH-02 **Plans:** 1 plans ### Phase 2: API **Goal:** Build API **Requirements:** API-01 ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements ### Authentication - [ ] **AUTH-01**: User can sign up with email - [ ] **AUTH-02**: User can log in - [ ] **AUTH-03**: User can reset password ### API - [ ] **API-01**: REST endpoints ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | Pending | | AUTH-02 | Phase 1 | Pending | | AUTH-03 | Phase 2 | Pending | | API-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); // Checkboxes updated for phase 1 requirements assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked'); assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked'); // Other requirements unchanged assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked'); assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked'); // Traceability table updated assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete'); assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete'); assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending'); assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending'); }); test('#2245 F1: phase complete traceability write is not fooled by an earlier Out of Scope table', () => { // Same class as the milestone.cts F1 regression: the shipped requirements // template puts an `## Out of Scope` table (`| Feature | Reason |`, no // Status column) BEFORE `## Traceability` — an unscoped updateTableCell // call binds to the FIRST table in the file (Out of Scope) and silently // fails, leaving the Traceability row unflipped while the checkbox still // flips and the command reports requirements_updated:true. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** AUTH-01 **Plans:** 1 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements ### Authentication - [ ] **AUTH-01**: User can sign up with email ## Out of Scope | Feature | Reason | |---------|--------| | Foo | Bar | ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked'); assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'the Traceability row (not the Out of Scope table) must be flipped to Complete'); assert.ok(req.includes('| Foo | Bar |'), 'the Out of Scope table must be left untouched'); }); test('handles requirements with bracket format [REQ-01, REQ-02]', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** [AUTH-01, AUTH-02] **Plans:** 1 plans ### Phase 2: API **Goal:** Build API **Requirements:** [API-01] ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements ### Authentication - [ ] **AUTH-01**: User can sign up with email - [ ] **AUTH-02**: User can log in - [ ] **AUTH-03**: User can reset password ### API - [ ] **API-01**: REST endpoints ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | Pending | | AUTH-02 | Phase 1 | Pending | | AUTH-03 | Phase 2 | Pending | | API-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); // Checkboxes updated for phase 1 requirements (brackets stripped) assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked'); assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked'); // Other requirements unchanged assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked'); assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked'); // Traceability table updated assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete'); assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete'); assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending'); assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending'); }); test('handles phase with no requirements mapping', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Setup ### Phase 1: Setup **Goal:** Project setup (no requirements) **Plans:** 1 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **REQ-01**: Some requirement ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | REQ-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // REQUIREMENTS.md should be unchanged const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [ ] **REQ-01**'), 'REQ-01 should remain unchecked'); assert.ok(req.includes('| REQ-01 | Phase 2 | Pending |'), 'REQ-01 should remain Pending'); }); test('handles missing REQUIREMENTS.md gracefully', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation **Requirements:** REQ-01 ### Phase 1: Foundation **Goal:** Setup ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`); }); test('returns requirements_updated field in result', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** AUTH-01 **Plans:** 1 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **AUTH-01**: User can sign up ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const parsed = JSON.parse(result.output); assert.strictEqual(parsed.requirements_updated, true, 'requirements_updated should be true'); }); test('handles In Progress status in traceability table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Auth ### Phase 1: Auth **Goal:** User authentication **Requirements:** AUTH-01, AUTH-02 **Plans:** 1 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **AUTH-01**: User can sign up - [ ] **AUTH-02**: User can log in ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 1 | In Progress | | AUTH-02 | Phase 1 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'In Progress should become Complete'); assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'Pending should become Complete'); }); test('scoped regex does not cross phase boundaries', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Setup - [ ] Phase 2: Auth ### Phase 1: Setup **Goal:** Project setup **Plans:** 1 plans ### Phase 2: Auth **Goal:** User authentication **Requirements:** AUTH-01 **Plans:** 0 plans ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements ## v1 Requirements - [ ] **AUTH-01**: User can sign up ## Traceability | Requirement | Phase | Status | |-------------|-------|--------| | AUTH-01 | Phase 2 | Pending | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Setup\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); // Phase 1 has no Requirements field, so Phase 2's AUTH-01 should NOT be updated const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [ ] **AUTH-01**'), 'AUTH-01 should remain unchecked (belongs to Phase 2)'); assert.ok(req.includes('| AUTH-01 | Phase 2 | Pending |'), 'AUTH-01 should remain Pending (belongs to Phase 2)'); }); test('handles multi-level decimal phase without regex crash', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [x] Phase 3: Lorem - [x] Phase 3.2: Ipsum - [ ] Phase 3.2.1: Dolor Sit - [ ] Phase 4: Amet ### Phase 3: Lorem **Goal:** Setup **Plans:** 1/1 plans complete **Requirements:** LOR-01 ### Phase 3.2: Ipsum **Goal:** Build **Plans:** 1/1 plans complete **Requirements:** IPS-01 ### Phase 03.2.1: Dolor Sit Polish (INSERTED) **Goal:** Polish **Plans:** 1/1 plans complete ### Phase 4: Amet **Goal:** Deliver **Requirements:** AMT-01: Filter items by category with AND logic (items matching ALL selected categories) ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements - [ ] **LOR-01**: Lorem database schema - [ ] **IPS-01**: Ipsum rendering engine - [ ] **AMT-01**: Filter items by category ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State **Current Phase:** 03.2.1 **Current Phase Name:** Dolor Sit Polish **Status:** Execution complete **Current Plan:** 03.2.1-01 **Last Activity:** 2025-01-01 **Last Activity Description:** Working ` ); const p32 = path.join(tmpDir, '.planning', 'phases', '03.2-ipsum'); const p321 = path.join(tmpDir, '.planning', 'phases', '03.2.1-dolor-sit'); const p4 = path.join(tmpDir, '.planning', 'phases', '04-amet'); fs.mkdirSync(p32, { recursive: true }); fs.mkdirSync(p321, { recursive: true }); fs.mkdirSync(p4, { recursive: true }); fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 03.2.1', tmpDir); assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [ ] **AMT-01**'), 'AMT-01 should remain unchanged'); }); test('preserves Milestone column in 5-column progress table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Foundation | v1.0 | 0/1 | Planned | | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // eslint-disable-next-line local/no-unbounded-quantifier -- parses ROADMAP.md the test itself wrote via a fixed fixture string, bounded, not adversarial input const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); assert.strictEqual(cells[1], 'v1.0', 'Milestone column should be preserved'); assert.ok(cells[3].includes('Complete'), 'Status column should be Complete'); }); test('updates STATE.md with plain format fields (no bold)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: Only\n**Goal:** Test\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\nPhase: 1 of 1 (Only)\nStatus: In progress\nPlan: 01-01\nLast Activity: 2025-01-01\nLast Activity Description: Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-only'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(state.includes('All phases complete'), 'plain Status field should be updated'); assert.ok(state.includes('Not started'), 'plain Plan field should be updated'); // Verify compound format preserved assert.ok(state.match(/Phase:.*of\s+1/), 'should preserve "of N" in compound Phase format'); }); test('updates Plans Complete column in 4-column progress table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation - [ ] Phase 2: API ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ### Phase 2: API **Goal:** Build API ## Progress | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Foundation | 0/1 | Not started | - | | 2. API | 0/1 | Not started | - | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // eslint-disable-next-line local/no-unbounded-quantifier -- parses ROADMAP.md the test itself wrote via a fixed fixture string, bounded, not adversarial input const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 4, 'should have 4 columns'); assert.strictEqual(cells[1], '1/1', 'Plans Complete column should be updated to 1/1'); assert.ok(cells[2].includes('Complete'), 'Status column should be Complete'); }); test('updates Plans Complete column in 5-column progress table', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 1 plans ## Progress | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| | 1. Foundation | v1.0 | 0/1 | Planned | | ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); // eslint-disable-next-line local/no-unbounded-quantifier -- parses ROADMAP.md the test itself wrote via a fixed fixture string, bounded, not adversarial input const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m); assert.ok(rowMatch, 'table row should exist'); const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim()); assert.strictEqual(cells.length, 5, 'should have 5 columns'); assert.strictEqual(cells[2], '1/1', 'Plans Complete column should be updated to 1/1'); assert.ok(cells[3].includes('Complete'), 'Status column should be Complete'); }); test('marks plan-level checkboxes on phase complete', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 2 plans Plans: - [ ] 01-01-PLAN.md \u2014 Schema migration - [ ] 01-02-PLAN.md \u2014 Auth setup ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 checkbox should be checked'); assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 checkbox should be checked'); assert.ok(!roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should not remain unchecked'); assert.ok(!roadmap.includes('[ ] 01-02-PLAN.md'), 'plan 01-02 should not remain unchecked'); }); test('marks bold-wrapped plan-level checkboxes on phase complete', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] Phase 1: Foundation ### Phase 1: Foundation **Goal:** Setup **Plans:** 2 plans Plans: - [ ] **01-01**: Schema migration - [ ] **01-02**: Auth setup ` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok(roadmap.includes('[x] **01-01**'), 'bold plan 01-01 checkbox should be checked'); assert.ok(roadmap.includes('[x] **01-02**'), 'bold plan 01-02 checkbox should be checked'); assert.ok(!roadmap.includes('[ ] **01-01**'), 'bold plan 01-01 should not remain unchecked'); assert.ok(!roadmap.includes('[ ] **01-02**'), 'bold plan 01-02 should not remain unchecked'); }); }); // ───────────────────────────────────────────────────────────────────────────── // comparePhaseNum and normalizePhaseName (imported directly) // ───────────────────────────────────────────────────────────────────────────── const { comparePhaseNum, normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs'); describe('comparePhaseNum', () => { test('sorts integer phases numerically', () => { assert.ok(comparePhaseNum('2', '10') < 0); assert.ok(comparePhaseNum('10', '2') > 0); assert.strictEqual(comparePhaseNum('5', '5'), 0); }); test('sorts decimal phases correctly', () => { assert.ok(comparePhaseNum('12', '12.1') < 0); assert.ok(comparePhaseNum('12.1', '12.2') < 0); assert.ok(comparePhaseNum('12.2', '13') < 0); }); test('sorts letter-suffix phases correctly', () => { assert.ok(comparePhaseNum('12', '12A') < 0); assert.ok(comparePhaseNum('12A', '12B') < 0); assert.ok(comparePhaseNum('12B', '13') < 0); }); test('sorts hybrid phases correctly', () => { assert.ok(comparePhaseNum('12A', '12A.1') < 0); assert.ok(comparePhaseNum('12A.1', '12A.2') < 0); assert.ok(comparePhaseNum('12A.2', '12B') < 0); }); test('handles full sort order', () => { const phases = ['13', '12B', '12A.2', '12', '12.1', '12A', '12A.1', '12.2']; phases.sort(comparePhaseNum); assert.deepStrictEqual(phases, ['12', '12.1', '12.2', '12A', '12A.1', '12A.2', '12B', '13']); }); test('handles directory names with slugs', () => { const dirs = ['13-deploy', '12B-hotfix', '12A.1-bugfix', '12-foundation', '12.1-inserted', '12A-split']; dirs.sort(comparePhaseNum); assert.deepStrictEqual(dirs, [ '12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy' ]); }); test('case insensitive letter matching', () => { assert.ok(comparePhaseNum('12a', '12B') < 0); assert.ok(comparePhaseNum('12A', '12b') < 0); assert.strictEqual(comparePhaseNum('12a', '12A'), 0); }); test('sorts multi-level decimal phases correctly', () => { assert.ok(comparePhaseNum('3.2', '3.2.1') < 0); assert.ok(comparePhaseNum('3.2.1', '3.2.2') < 0); assert.ok(comparePhaseNum('3.2.1', '3.3') < 0); assert.ok(comparePhaseNum('3.2.1', '4') < 0); assert.strictEqual(comparePhaseNum('3.2.1', '3.2.1'), 0); }); test('falls back to localeCompare for non-phase strings', () => { const result = comparePhaseNum('abc', 'def'); assert.strictEqual(typeof result, 'number'); }); }); describe('normalizePhaseName', () => { test('pads single-digit integers', () => { assert.strictEqual(normalizePhaseName('3'), '03'); assert.strictEqual(normalizePhaseName('12'), '12'); }); test('handles decimal phases', () => { assert.strictEqual(normalizePhaseName('3.1'), '03.1'); assert.strictEqual(normalizePhaseName('12.2'), '12.2'); }); test('handles letter-suffix phases', () => { assert.strictEqual(normalizePhaseName('3A'), '03A'); assert.strictEqual(normalizePhaseName('12B'), '12B'); }); test('handles hybrid phases', () => { assert.strictEqual(normalizePhaseName('3A.1'), '03A.1'); assert.strictEqual(normalizePhaseName('12A.2'), '12A.2'); }); test('preserves letter case', () => { assert.strictEqual(normalizePhaseName('3a'), '03a'); assert.strictEqual(normalizePhaseName('12b.1'), '12b.1'); }); test('handles multi-level decimal phases', () => { assert.strictEqual(normalizePhaseName('3.2.1'), '03.2.1'); assert.strictEqual(normalizePhaseName('12.3.4'), '12.3.4'); }); test('returns non-matching input unchanged', () => { assert.strictEqual(normalizePhaseName('abc'), 'abc'); }); }); describe('letter-suffix phase sorting', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('lists letter-suffix phases in correct order', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12.1-inserted'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A-split'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A.1-bugfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12B-hotfix'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '13-deploy'), { recursive: true }); const result = runGsdTools('phases list', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual( output.directories, ['12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'], 'letter-suffix phases should sort correctly' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // milestone-scoped next-phase in phase complete // ───────────────────────────────────────────────────────────────────────────── describe('phase complete milestone-scoped next-phase', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('finds next phase within milestone, ignoring prior milestone dirs', () => { // ROADMAP lists phases 5-6 (current milestone v2.0) fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '## Roadmap v2.0: Release', '', '- [ ] Phase 5: Auth', '- [ ] Phase 6: Dashboard', '', '### Phase 5: Auth', '**Goal:** Add authentication', '**Plans:** 1 plans', '', '### Phase 6: Dashboard', '**Goal:** Build dashboard', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n' ); // Disk has dirs 01-06 (01-04 completed from prior milestone) for (let i = 1; i <= 4; i++) { const padded = String(i).padStart(2, '0'); const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-old-phase`); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan'); fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary'); } // Phase 5 — completing this one const p5 = path.join(tmpDir, '.planning', 'phases', '05-auth'); fs.mkdirSync(p5, { recursive: true }); fs.writeFileSync(path.join(p5, '05-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p5, '05-01-SUMMARY.md'), '# Summary'); // Phase 6 — next phase in milestone fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, false, 'should NOT be last phase — phase 6 is in milestone'); assert.strictEqual(output.next_phase, '06', 'next phase should be 06'); }); test('detects last phase when only milestone phases are considered', () => { // ROADMAP lists only phase 5 (current milestone) fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '## Roadmap v2.0: Release', '', '### Phase 5: Auth', '**Goal:** Add authentication', '**Plans:** 1 plans', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n' ); // Disk has dirs 01-06 but only 5 is in ROADMAP for (let i = 1; i <= 6; i++) { const padded = String(i).padStart(2, '0'); const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan'); fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary'); } const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Without the fix, dirs 06 on disk would make is_last_phase=false // With the fix, only phase 5 is in milestone, so it IS the last phase assert.strictEqual(output.is_last_phase, true, 'should be last phase — only phase 5 is in milestone'); assert.strictEqual(output.next_phase, null, 'no next phase in milestone'); }); }); // ───────────────────────────────────────────────────────────────────────────── // #2028 — phase.complete milestone-end inference + workstream root-fallback guard // ───────────────────────────────────────────────────────────────────────────── describe('#2028 — phase complete milestone-end + workstream guard', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); // A complement phase numbered AFTER Phase 9 but executed first. Completing the // numerically-highest phase must not read as milestone-end while a lower phase // is still outstanding (the isLastPhase blocks only checked for HIGHER phases). test('does NOT stamp "All phases complete" when a lower-numbered phase is still outstanding', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n- [ ] Phase 9: Introspection\n- [ ] Phase 10: Complement\n\n### Phase 9: Introspection\n**Goal:** baseline\n\n### Phase 10: Complement\n**Goal:** complement\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 10\n**Status:** In progress\n**Current Plan:** 10-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p10 = path.join(tmpDir, '.planning', 'phases', '10-complement'); fs.mkdirSync(p10, { recursive: true }); fs.writeFileSync(path.join(p10, '10-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p10, '10-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 10', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.is_last_phase, false, 'Phase 10 is numerically highest but Phase 9 is outstanding → not milestone-end', ); // The outstanding lower phase IS the real next actionable item — STATE.md must // advance to it (the gap), not park on the just-completed Phase 10. assert.strictEqual(String(Number(output.next_phase)), '9', 'next_phase should point at the outstanding Phase 9'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok( !/Milestone complete|All phases complete/i.test(state), 'STATE.md must NOT flip to milestone-complete while a lower phase is outstanding', ); assert.ok(/Ready to plan/i.test(state), 'status should be "Ready to plan"'); assert.match( state, /\*\*Current Phase:\*\*\s*0*9\b/, 'Current Phase must advance to the outstanding Phase 9, not stay on the completed Phase 10', ); assert.doesNotMatch( state, /\*\*Current Phase:\*\*\s*10\b/, 'Current Phase must NOT remain on the just-completed Phase 10', ); }); // Guard against over-correction: when every earlier phase is [x], completing // the numerically-highest phase IS still the milestone end. test('still detects milestone-end when all lower phases are checked complete', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n- [x] Phase 9: Introspection\n- [ ] Phase 10: Complement\n\n### Phase 9: Introspection\n**Goal:** baseline\n\n### Phase 10: Complement\n**Goal:** complement\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 10\n**Status:** In progress\n**Current Plan:** 10-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p10 = path.join(tmpDir, '.planning', 'phases', '10-complement'); fs.mkdirSync(p10, { recursive: true }); fs.writeFileSync(path.join(p10, '10-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p10, '10-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 10', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, true, 'all lower phases complete → Phase 10 is milestone-end'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(/All phases complete/i.test(state), 'status should be "All phases complete"'); }); // The lower-phase scan must not treat an unrelated checklist line that merely // mentions "Phase N" (no `:` after the number) as an outstanding phase — the // checkbox regex is anchored like the sibling phase scan. test('does not treat an unrelated checklist line mentioning a phase number as outstanding', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n- [ ] Add regression coverage for Phase 3 rollback\n- [ ] Phase 5: Final\n\n### Phase 5: Final\n**Goal:** end\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 05\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p5 = path.join(tmpDir, '.planning', 'phases', '05-final'); fs.mkdirSync(p5, { recursive: true }); fs.writeFileSync(path.join(p5, '05-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p5, '05-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.is_last_phase, true, 'the "Phase 3 rollback" prose line must NOT be read as an outstanding Phase 3', ); }); // #1912 parity: in workstream mode with no active workstream, planningDir(cwd) // resolves to root .planning — writing STATE.md/ROADMAP.md into the shared root // that other workstreams read. Refuse instead of silently writing root. test('refuses to write root in workstream mode when no workstream is resolved', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta'), { recursive: true }); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\n**Current Phase:** 01\n**Status:** In progress\n', ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n- [ ] Phase 1: A\n\n### Phase 1: A\n**Goal:** x\n', ); const result = runGsdTools('phase complete 1', tmpDir); assert.equal(result.success, false, 'should refuse rather than silently writing root STATE/ROADMAP'); assert.match(result.error || '', /workstream|--ws/i, 'error should name the workstream requirement'); }); // An explicit --ws satisfies the guard (it sets GSD_WORKSTREAM upstream) AND // targets that workstream — the write must land in the workstream's own // STATE.md/ROADMAP.md, leaving root untouched. test('--ws satisfies the guard and writes the workstream, not root', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'beta'), { recursive: true }); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha'); fs.mkdirSync(path.join(wsDir, 'phases', '01-only'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Only\n**Goal:** x\n'); fs.writeFileSync( path.join(wsDir, 'STATE.md'), '# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** W\n', ); fs.writeFileSync(path.join(wsDir, 'phases', '01-only', '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(wsDir, 'phases', '01-only', '01-01-SUMMARY.md'), '# Summary'); fs.writeFileSync( path.join(wsDir, 'phases', '01-only', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n', ); // A distinct root STATE.md that must be left byte-for-byte untouched. const rootState = '# ROOT State\n\n**Current Phase:** 99\n**Status:** Root sentinel\n'; fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), rootState); const result = runGsdTools('phase complete 1 --ws alpha', tmpDir); assert.ok(result.success, `--ws alpha should complete in the workstream: ${result.error}`); // Root STATE.md must be untouched — the write landed in the workstream. assert.strictEqual( fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'), rootState, 'root STATE.md must NOT be written when --ws targets a workstream', ); // The workstream's own STATE.md advanced (single phase → all phases complete). const wsState = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8'); assert.match(wsState, /All phases complete/i, "the workstream's STATE.md should be the one updated"); }); // The guard only fires in workstream mode — a flat project (no workstreams dir) // completes normally. test('flat mode (no workstreams dir) still completes normally', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n### Phase 1: Only\n**Goal:** x\n` ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-only'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `flat mode should still complete: ${result.error}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // exact token matching (no prefix collisions) // ───────────────────────────────────────────────────────────────────────────── describe('phase resolution uses exact token matching', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('1009 must NOT match 1009A-feature-consistency when 1009 dir is absent', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency')); fs.writeFileSync(path.join(phasesDir, '1009A-feature-consistency', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 1009', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'should NOT find phase 1009 when only 1009A exists'); }); test('1009 matches 1009-pipeline-accuracy-fix when both exist', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '1009-pipeline-accuracy-fix')); fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency')); fs.writeFileSync(path.join(phasesDir, '1009-pipeline-accuracy-fix', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 1009', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'should find phase 1009'); assert.ok( output.directory.includes('1009-pipeline-accuracy-fix'), `should match 1009-pipeline-accuracy-fix, got: ${output.directory}` ); }); test('999.6 must NOT match 999.60-episode-processing when 999.6 dir is absent', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing')); fs.writeFileSync(path.join(phasesDir, '999.60-episode-processing', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 999.6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, false, 'should NOT find phase 999.6 when only 999.60 exists'); }); test('999.6 matches 999.6-ground-truth-dataset when both exist', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '999.6-ground-truth-dataset')); fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing')); fs.writeFileSync(path.join(phasesDir, '999.6-ground-truth-dataset', 'PLAN.md'), '# Plan'); const result = runGsdTools('find-phase 999.6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.found, true, 'should find phase 999.6'); assert.ok( output.directory.includes('999.6-ground-truth-dataset'), `should match 999.6-ground-truth-dataset, got: ${output.directory}` ); }); test('normal non-colliding phases still resolve', () => { const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '01-foundation')); fs.mkdirSync(path.join(phasesDir, '02-implementation')); fs.writeFileSync(path.join(phasesDir, '01-foundation', 'PLAN.md'), '# Plan'); fs.writeFileSync(path.join(phasesDir, '02-implementation', 'PLAN.md'), '# Plan'); const r1 = runGsdTools('find-phase 1', tmpDir); assert.ok(r1.success, `Command failed for phase 1: ${r1.error}`); const o1 = JSON.parse(r1.output); assert.strictEqual(o1.found, true, 'should find phase 1'); assert.ok(o1.directory.includes('01-foundation'), `should match 01-foundation, got: ${o1.directory}`); const r2 = runGsdTools('find-phase 2', tmpDir); assert.ok(r2.success, `Command failed for phase 2: ${r2.error}`); const o2 = JSON.parse(r2.output); assert.strictEqual(o2.found, true, 'should find phase 2'); assert.ok(o2.directory.includes('02-implementation'), `should match 02-implementation, got: ${o2.directory}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // phase complete — Performance Metrics gate (Step 2 — Gate 4) // ───────────────────────────────────────────────────────────────────────────── describe('phase complete updates Performance Metrics', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('after cmdPhaseComplete: Performance Metrics has updated total plans count', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# Project State\n\n**Current Phase:** 2\n**Status:** Executing Phase 2\n**Total Plans in Phase:** 3\n**Current Plan:** 3\n**Completed Phases:** 0\n**Total Phases:** 3\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n` ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n'); fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan 2\n'); fs.writeFileSync(path.join(phaseDir, '02-03-PLAN.md'), '# Plan 3\n'); fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '02-03-SUMMARY.md'), '# Summary\n'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n` ); const result = runVerifiedPhaseComplete('phase complete 2', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3'); }); test('after cmdPhaseComplete: By Phase table has row for completed phase', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# Project State\n\n**Current Phase:** 1\n**Status:** Executing Phase 1\n**Total Plans in Phase:** 2\n**Current Plan:** 2\n**Completed Phases:** 0\n**Total Phases:** 2\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n` ); const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n` ); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(stateAfter.match(/\|\s*1\s*\|\s*2\s*\|/), 'By Phase table should have row for phase 1 with 2 plans'); // Row must appear BEFORE the next section, not after it (regression: empty table body regex) const rowIdx = stateAfter.indexOf('| 1 |'); const accIdx = stateAfter.indexOf('## Accumulated Context'); if (accIdx !== -1) { assert.ok(rowIdx < accIdx, 'By Phase row must appear before ## Accumulated Context section'); } }); }); // ───────────────────────────────────────────────────────────────────────────── // phase complete — backlog phase (999.x) exclusion (#2129) // ───────────────────────────────────────────────────────────────────────────── describe('phase complete excludes 999.x backlog from next-phase (#2129)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('next phase skips 999.x backlog dirs and falls back to roadmap', () => { // ROADMAP defines phases 1, 2, 3 and a backlog 999.1 fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 1: Setup', '- [ ] Phase 2: Core', '- [ ] Phase 3: Polish', '- [ ] Phase 999.1: Backlog idea', '', '### Phase 1: Setup', '**Goal:** Initial setup', '', '### Phase 2: Core', '**Goal:** Build core', '', '### Phase 3: Polish', '**Goal:** Polish everything', '', '### Phase 999.1: Backlog idea', '**Goal:** Parked idea', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), [ '# State', '', '**Current Phase:** 02', '**Status:** In progress', '**Current Plan:** 02-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working', ].join('\n') ); // Phase 1 and 2 exist on disk, phase 3 does NOT exist yet, 999.1 DOES exist const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const p2 = path.join(tmpDir, '.planning', 'phases', '02-core'); fs.mkdirSync(p2, { recursive: true }); fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '# Summary'); // Backlog stub on disk — this is what triggers the bug fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-idea'), { recursive: true }); const result = runVerifiedPhaseComplete('phase complete 2', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // #3350: with stage 3 (#2028 lowest-outstanding) no longer gated behind // stages 1-2 missing, the unchecked Phase 1 row outranks the positionally- // next Phase 3 heading — a phase is complete iff its roadmap checkbox is // `[x]` (#2028), and Phase 1's row is unchecked despite its dir on disk // (same drift shape #2949's realLowerOutstandingPhaseStillSelected pins). // The #2129 contract itself is unchanged: 999.x is NEVER selected. assert.notEqual(output.next_phase, '999.1', '999.x backlog must never be next_phase'); assert.strictEqual(output.next_phase, '1', 'lowest unchecked phase (1) wins over positional 3 (#3350); never 999.1'); assert.strictEqual(output.is_last_phase, false, 'should not be last phase'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #1962 — normalizePhaseName preserves letter suffix case // (consolidated from tests/bug-1962-phase-suffix-case.test.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('bug #1962: normalizePhaseName preserves letter suffix case', () => { test('lowercase suffix preserved: 16c → 16c', () => { assert.equal(normalizePhaseName('16c'), '16c'); }); test('uppercase suffix preserved: 16C → 16C', () => { assert.equal(normalizePhaseName('16C'), '16C'); }); test('single digit padded with lowercase suffix: 1a → 01a', () => { assert.equal(normalizePhaseName('1a'), '01a'); }); test('single digit padded with uppercase suffix: 1A → 01A', () => { assert.equal(normalizePhaseName('1A'), '01A'); }); test('no suffix unchanged: 16 → 16', () => { assert.equal(normalizePhaseName('16'), '16'); }); test('decimal suffix preserved: 16.1 → 16.1', () => { assert.equal(normalizePhaseName('16.1'), '16.1'); }); test('letter + decimal preserved: 16c.2 → 16c.2', () => { assert.equal(normalizePhaseName('16c.2'), '16c.2'); }); test('project code prefix stripped, suffix case preserved: CK-01a → 01a', () => { assert.equal(normalizePhaseName('CK-01a'), '01a'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #1998 — phase complete updates overview checkbox // (consolidated from tests/bug-1998-phase-complete-checkbox.test.cjs) // ───────────────────────────────────────────────────────────────────────────── /** * Run `gsd-tools phase complete ` for the phase-complete regression * suites and return its stdout. * * `phase complete` writes ROADMAP.md as its LAST step, after a read-heavy * parse/lock sequence (ROADMAP read, two extractCurrentMilestone STATE.md * parses, REQUIREMENTS read, phase-dir scan, STATE read — all before the single * writePlanningFileSet flush). Under the high-concurrency docker run (~672 test * files in parallel), a tight 10s timeout could fire mid-parse and SIGTERM the * subprocess BEFORE that write landed, leaving ROADMAP.md untouched. Call sites * that used a bare `catch {}` then silently proceeded to assert on the pristine * file — an intermittent "checkbox not checked" failure (bug #1998 flake). * * Two-part fix, no retry loop: * 1. A generous timeout so the test's own timer never kills the subprocess * under load (10s cold-node startup × 672-way CPU/IO contention was the * real culprit — all I/O is scoped to tmpDir, so there is no cross-process * race to blame). * 2. Never silently swallow a signal/timeout kill: it means the process was * terminated before completing its writes, so we surface it loudly with * context instead of letting it masquerade as an assertion failure. A * *clean* non-zero exit is still tolerated when `tolerateExit` is set, * because the ROADMAP write has already landed before any post-write step * that may exit non-zero in these minimal fixtures. */ function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) { writePassedVerificationForPhase(tmpDir, phase); try { return execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', phase], { cwd: tmpDir, timeout: 60000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); } catch (err) { // A signal/timeout kill terminated the process before it finished writing — // never tolerate it; surface it with whatever output was captured. if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') { throw new Error( `gsd-tools phase complete ${phase} was killed before completion ` + `(signal=${err.signal}, code=${err.code}). ` + `stdout=${err.stdout || ''} stderr=${err.stderr || ''}` ); } if (tolerateExit) { return `${err.stdout || ''}${err.stderr || ''}`; } throw err; } } describe('bug #1998: phase complete updates overview checkbox', () => { let tmpDir; let planningDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1998-')); planningDir = path.join(tmpDir, '.planning'); fs.mkdirSync(planningDir, { recursive: true }); // Minimal config fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }) ); // Minimal STATE.md fs.writeFileSync( path.join(planningDir, 'STATE.md'), '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' ); }); afterEach(() => { cleanup(tmpDir); }); test('checkbox updated when no archived milestones exist', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '## Phases', '', '- [ ] **Phase 1: Foundation** - core setup', '- [ ] **Phase 2: Features** - add features', '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', '| 2. Features | 0/1 | Pending | - |', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked'); assert.match(result, /- \[ \] \*\*Phase 2: Features\*\*/, 'phase 2 checkbox should remain unchecked'); }); test('checkbox updated when archived milestones exist in
', () => { const phasesDir = path.join(planningDir, 'phases', '01-setup'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap v2.0', '', '## Phases', '', '- [ ] **Phase 1: Setup** - initial setup', '- [ ] **Phase 2: Build** - build features', '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Setup | 0/1 | Pending | - |', '| 2. Build | 0/1 | Pending | - |', '', '
', 'v1.0 (Archived)', '', '## v1.0 Phases', '- [x] **Phase 1: Init** - initialization', '- [x] **Phase 2: Deploy** - deployment', '', '
', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked'); assert.match(result, /- \[ \] \*\*Phase 2: Build\*\*/, 'phase 2 checkbox should remain unchecked'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #2005 — phase complete inside
updates plan count // (consolidated from tests/bug-2005-phase-complete-details.test.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('bug #2005: phase complete updates plan count when milestone is inside
', () => { let tmpDir; let planningDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2005-')); planningDir = path.join(tmpDir, '.planning'); fs.mkdirSync(planningDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }) ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), '---\ncurrent_phase: 1\nstatus: executing\nmilestone: v2.0\n---\n# State\n' ); }); afterEach(() => { cleanup(tmpDir); }); test('plan count is updated when current milestone is wrapped in
', () => { const phasesDir = path.join(planningDir, 'phases', '01-setup'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '
', 'v1.0 (shipped)', '', '## v1.0 Phases', '- [x] **Phase 0: Bootstrap** - shipped', '', '
', '', '
', 'v2.0 (in progress)', '', '## v2.0 Phases', '', '- [ ] **Phase 1: Setup** - initial setup', '- [ ] **Phase 2: Build** - build features', '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Setup | 0/1 | Pending | - |', '| 2. Build | 0/1 | Pending | - |', '', '### Phase 1: Setup', '', '**Plans:** 0/1 plans complete', '', '### Phase 2: Build', '', '**Plans:** 0/1 plans complete', '', '
', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match( result, /\*\*Plans:\*\*\s*1\/1 plans complete/, 'plan count in Phase 1 section must be updated to 1/1 plans complete' ); assert.match( result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'Phase 1 checkbox must be checked after completion' ); assert.match( result, /- \[ \] \*\*Phase 2: Build\*\*/, 'Phase 2 checkbox must remain unchecked' ); }); test('phase complete with all milestones in
does not corrupt phase 2 plan count', () => { const phasesDir = path.join(planningDir, 'phases', '01-setup'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '
', 'v2.0 (in progress)', '', '## v2.0 Phases', '', '- [ ] **Phase 1: Setup** - initial setup', '- [ ] **Phase 2: Build** - build features', '', '### Phase 1: Setup', '', '**Plans:** 0/1 plans complete', '', '### Phase 2: Build', '', '**Plans:** 0/2 plans complete', '', '
', ].join('\n')); runPhaseComplete(tmpDir, { tolerateExit: true }); const result = fs.readFileSync(roadmapPath, 'utf-8'); assert.match( result, /Phase 2: Build[\s\S]*?\*\*Plans:\*\*\s*0\/2 plans complete/, 'Phase 2 plan count must remain 0/2 (untouched)' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #2526 — phase complete warns about unregistered REQ-IDs // (consolidated from tests/bug-2526-phase-complete-req-discovery.test.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => { let tmpDir; let planningDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-')); planningDir = path.join(tmpDir, '.planning'); fs.mkdirSync(planningDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: '' }) ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), '---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n' ); }); afterEach(() => { cleanup(tmpDir); }); test('emits warning for REQ-IDs in body but missing from Traceability table', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Build core', '**Requirements:** REQ-001', '**Plans:** 1 plans', '', 'Plans:', '- [x] 01-1-PLAN.md', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', ].join('\n')); const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); fs.writeFileSync(reqPath, [ '# Requirements', '', '## Functional Requirements', '', '- [x] **REQ-001**: Core data model', '- [ ] **REQ-002**: User authentication', '- [ ] **REQ-003**: API endpoints', '', '## Traceability', '', '| REQ-ID | Phase | Status |', '|--------|-------|--------|', '| REQ-001 | 1 | Pending |', ].join('\n')); const combined = runPhaseComplete(tmpDir); assert.match(combined, /REQ-002/, 'output should mention REQ-002 as missing from Traceability table'); assert.match(combined, /REQ-003/, 'output should mention REQ-003 as missing from Traceability table'); }); test('no warning when all body REQ-IDs are present in Traceability table', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Build core', '**Requirements:** REQ-001, REQ-002', '**Plans:** 1 plans', '', 'Plans:', '- [x] 01-1-PLAN.md', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', ].join('\n')); const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); fs.writeFileSync(reqPath, [ '# Requirements', '', '## Functional Requirements', '', '- [x] **REQ-001**: Core data model', '- [x] **REQ-002**: User authentication', '', '## Traceability', '', '| REQ-ID | Phase | Status |', '|--------|-------|--------|', '| REQ-001 | 1 | Pending |', '| REQ-002 | 1 | Pending |', ].join('\n')); const combined = runPhaseComplete(tmpDir); assert.doesNotMatch( combined, /unregistered|missing.*traceability|not in.*traceability/i, 'no warning should appear when all REQ-IDs are in the table' ); }); test('warning includes all missing REQ-IDs, not just the first', () => { const phasesDir = path.join(planningDir, 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan 1\n' ); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.' ); const roadmapPath = path.join(planningDir, 'ROADMAP.md'); fs.writeFileSync(roadmapPath, [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Build core', '**Requirements:** REQ-001', '**Plans:** 1 plans', '', 'Plans:', '- [x] 01-1-PLAN.md', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', ].join('\n')); const reqPath = path.join(planningDir, 'REQUIREMENTS.md'); fs.writeFileSync(reqPath, [ '# Requirements', '', '- [x] **REQ-001**: Core data model', '- [ ] **REQ-002**: User auth', '- [ ] **REQ-003**: API', '- [ ] **REQ-004**: Reports', '', '## Traceability', '', '| REQ-ID | Phase | Status |', '|--------|-------|--------|', '| REQ-001 | 1 | Pending |', ].join('\n')); const combined = runPhaseComplete(tmpDir); assert.match(combined, /REQ-002/, 'should warn about REQ-002'); assert.match(combined, /REQ-003/, 'should warn about REQ-003'); assert.match(combined, /REQ-004/, 'should warn about REQ-004'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3287 — phase-dir prefix parity across creation paths // (consolidated from tests/bug-3287-phase-dir-prefix-parity.test.cjs) // ───────────────────────────────────────────────────────────────────────────── // ─── shared fixture for bug-3287 ───────────────────────────────────────────── function makeXRProject(tmpDir) { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ project_code: 'XR' }), ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '**Goal:** Setup project', '**Plans:** 0 plans', '', '---', '', ].join('\n'), ); } describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('phase.add creates XR-02- when project_code is XR', () => { makeXRProject(tmpDir); const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `phase.add failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_number, 2, 'phase number should be 2'); const phasesDir = path.join(tmpDir, '.planning', 'phases'); const dirs = fs.readdirSync(phasesDir); const prefixedDirs = dirs.filter(d => d.startsWith('XR-')); assert.ok( prefixedDirs.length > 0, `Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`, ); assert.ok( dirs.some(d => d === 'XR-02-auth-service'), `Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`, ); }); }); describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { makeXRProject(tmpDir); const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `init phase-op failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); assert.ok( typeof output.expected_phase_dir === 'string', `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, ); assert.ok( output.expected_phase_dir.includes('XR-'), `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, ); assert.ok( output.expected_phase_dir.includes('foundation'), `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, ); }); test('expected_phase_dir is null when no project_code is set', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({}), ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', ); const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `init phase-op failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_dir, null); assert.ok( typeof output.expected_phase_dir === 'string', `expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`, ); assert.ok( !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, ); }); }); describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => { makeXRProject(tmpDir); const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `init plan-phase failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap'); assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)'); assert.ok( typeof output.expected_phase_dir === 'string', `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, ); assert.ok( output.expected_phase_dir.includes('XR-'), `expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`, ); assert.ok( output.expected_phase_dir.includes('foundation'), `expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`, ); }); test('expected_phase_dir omits prefix when project_code is not set', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({}), ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n', ); const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `init plan-phase failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phase_dir, null); assert.ok( typeof output.expected_phase_dir === 'string', `expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`, ); assert.ok( !output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/), `expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`, ); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3298 — phase-dir prefix drift in workflows // (consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { const IMPORT_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'import.md'); const BACKLOG_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'add-backlog.md'); function readWorkflow(filePath) { try { return fs.readFileSync(filePath, 'utf8'); } catch (err) { throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`); } } function containsBareTemplateMkdir(content) { return /mkdir[^`\r\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content); } function containsBareShellVarMkdir(content) { return /mkdir[^`\r\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content) || /mkdir[^`\r\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content); } // The plan-milestone-gaps arm was removed along with that workflow file in // #3560 (its command was deleted by #2790). The import.md and // add-backlog.md arms below still guard the same phase-dir prefix drift. describe('bug-3298 — import.md must not construct bare {NN}-{slug} phase dirs', () => { test('workflow file exists', () => { assert.ok(fs.existsSync(IMPORT_WF), `import.md must exist at ${IMPORT_WF}`); }); test('plan_convert step must not use bare {NN}-{slug} mkdir pattern', () => { const content = readWorkflow(IMPORT_WF); assert.ok( !containsBareTemplateMkdir(content), 'import.md must not contain bare mkdir .planning/phases/{NN}-{slug} pattern — use expected_phase_dir from init.phase-op', ); }); test('plan_convert step must use expected_phase_dir for directory creation', () => { const content = readWorkflow(IMPORT_WF); assert.ok( content.includes('expected_phase_dir'), 'import.md must use expected_phase_dir (from init.phase-op) to create phase directory with project_code prefix', ); }); test('plan_convert step must call init.phase-op to resolve the prefixed dir', () => { const content = readWorkflow(IMPORT_WF); assert.ok( content.includes('init.phase-op') || content.includes('init phase-op'), 'import.md must call gsd-sdk query init.phase-op to get expected_phase_dir with project_code prefix', ); }); }); describe('bug-3298 — add-backlog.md must apply project_code prefix when creating 999.x dirs', () => { test('workflow file exists', () => { assert.ok(fs.existsSync(BACKLOG_WF), `add-backlog.md must exist at ${BACKLOG_WF}`); }); test('step 4 must not use bare ${NEXT}-${SLUG} mkdir without project_code prefix', () => { const content = readWorkflow(BACKLOG_WF); assert.ok( !containsBareShellVarMkdir(content), 'add-backlog.md must not create .planning/phases/${NEXT}-${SLUG} without a project_code prefix variable — apply ${PREFIX} (or equivalent) before ${NEXT}', ); }); test('step 4 must reference project_code or a prefix variable before the phase number', () => { const content = readWorkflow(BACKLOG_WF); const hasProjectCodeRef = content.includes('project_code') || content.includes('PROJECT_CODE'); const hasPrefixVar = content.includes('${PREFIX}') || content.includes('${PHASE_PREFIX}') || content.includes('${CODE}'); assert.ok( hasProjectCodeRef || hasPrefixVar, 'add-backlog.md must read project_code (or use a PREFIX variable) to apply the project_code prefix to the 999.x phase directory name', ); }); }); } // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3517 — phase.complete leaves STATE.md with stale fields // (consolidated from tests/bug-3517-phase-complete-state-md-staleness.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { // Typed STATE.md surfaces (#3090) — replaces raw regex/substring matching // on STATE.md content written by phase.complete in this bug-#3517 block. const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs'); const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); const { parseMarkdownTable } = require('../gsd-core/bin/lib/markdown-table.cjs'); const { parsePhaseFromProse } = require('../gsd-core/bin/lib/phase-id.cjs'); function runSdkQuery(args, cwd) { if (Array.isArray(args) && args[0] === 'phase.complete') { writePassedVerificationForPhase(cwd, args[1]); } const result = runGsdTools(args, cwd); if (!result.success) return { success: false, error: result.error }; try { const parsed = JSON.parse(result.output || '{}'); return { success: true, data: parsed }; } catch (err) { return { success: false, error: err.message }; } } function setupPhase3517Project(tmpDir) { const planningDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planningDir, 'phases'); fs.mkdirSync(planningDir, { recursive: true }); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }), ); const roadmap = [ '# Roadmap', '', '## Current Milestone: v3.0', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 4. | 3/3 | Complete | 2026-04-01 |', '| 5. | 7/7 | In Progress | |', '| 6. | 0/5 | Not Started | |', '', '- [x] Phase 4: Foundation (completed 2026-04-01)', '- [ ] Phase 5: Core API', '- [ ] Phase 6: Integration', '', '### Phase 4: Foundation', '', '**Goal:** Foundation work', '**Plans:** 3/3 plans complete', '', '### Phase 5: Core API', '', '**Goal:** Build core API layer', '**Plans:** 7 plans', '', 'Plans:', '- [ ] 05-01 plan', '- [ ] 05-02 plan', '- [ ] 05-03 plan', '- [ ] 05-04 plan', '- [ ] 05-05 plan', '- [ ] 05-06 plan', '- [ ] 05-07 plan', '', '### Phase 6: Integration', '', '**Goal:** Integration work', '**Plans:** 5 plans', '', '---', '*Last updated: 2026-05-14*', ].join('\n'); fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); const state = [ '---', 'gsd_state_version: 1.0', 'milestone: v3.0', 'milestone_name: Core Platform', 'status: executing', 'stopped_at: Completed 05-03-PLAN.md', 'last_updated: 2026-05-10T08:00:00.000Z', 'progress:', ' total_phases: 3', ' completed_phases: 1', ' total_plans: 15', ' completed_plans: 6', ' percent: 33', '---', '', '# Project State', '', '## Current Position', '', '**Current focus:** Phase 5 — Core API', 'Phase: 5 of 3 (Core API) — EXECUTING', 'Plan: 7 of 7', 'Status: Executing Phase 5', 'Last activity: 2026-05-10', '', '## Progress', '', 'Progress: [████████████░░░░] 40% (1/3 phases complete before Phase 5 closeout)', '', '## Performance Metrics', '', '**Velocity:**', '', '- Total plans completed: 6', '- Average duration: 2h', '- Total execution time: 14 hours', '- Window: 2026-04-01 to 2026-05-10', '', '**By Phase:**', '', '| Phase | Plans | Total | Avg/Plan |', '|-------|-------|-------|----------|', '| 4 | 3 | - | - |', '', '## Session Continuity', '', 'Last session: 2026-05-10T08:00:00.000Z', 'Stopped at: Completed 05-07-PLAN.md', ].join('\n'); fs.writeFileSync(path.join(planningDir, 'STATE.md'), state); const phase5Dir = path.join(phasesDir, '05-core-api'); fs.mkdirSync(phase5Dir, { recursive: true }); for (let i = 1; i <= 7; i++) { const padded = String(i).padStart(2, '0'); fs.writeFileSync(path.join(phase5Dir, `05-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); fs.writeFileSync(path.join(phase5Dir, `05-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); } const phase4Dir = path.join(phasesDir, '04-foundation'); fs.mkdirSync(phase4Dir, { recursive: true }); for (let i = 1; i <= 3; i++) { const padded = String(i).padStart(2, '0'); fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8'); fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8'); } // Disk-strict completion (ADR-3180 §7.4, #3186): Phase 4 is already // shipped per the ROADMAP checklist/table above — a passing // *-VERIFICATION.md is what actually makes it count as complete now // (runSdkQuery's writePassedVerificationForPhase only covers the phase // under test, phase 5, not this already-complete phase 4 fixture). fs.writeFileSync( path.join(phase4Dir, '04-VERIFICATION.md'), ['---', 'status: passed', '---', '', '# Verification', ''].join('\n'), ); const phase6Dir = path.join(phasesDir, '06-integration'); fs.mkdirSync(phase6Dir, { recursive: true }); return { planningDir, phase5Dir }; } function setupPhase1316Project(tmpDir) { const planningDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planningDir, 'phases'); fs.mkdirSync(planningDir, { recursive: true }); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'ROADMAP.md'), [ '# Roadmap', '', '## Current Milestone: v3.0', '', '- [ ] Phase 32: Backlog-Closeout Lib Extraction', '- [ ] Phase 33: Follow Up Implementation', '', '### Phase 32: Backlog-Closeout Lib Extraction', '**Goal:** Complete closeout extraction', '**Plans:** 1 plans', '', '### Phase 33: Follow Up Implementation', '**Goal:** Continue implementation', ].join('\n'), ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), [ '---', 'gsd_state_version: 1.0', 'status: executing', 'current_phase: "32"', 'last_activity: "2026-06-14"', 'progress:', ' total_phases: 2', ' completed_phases: 0', ' total_plans: 1', ' completed_plans: 0', ' percent: 0', '---', '', '# Project State', '', '## Current Position', '', 'Phase: 32 — Backlog-Closeout Lib Extraction', 'Plan: 1 of 1', 'Status: Executing Phase 32', 'Last activity: 2026-06-14 — recorded planning complete', '', '## Session', '', 'Last session: 2026-06-14T00:00:00.000Z', ].join('\n'), ); const phase32Dir = path.join(phasesDir, '32-backlog-closeout-lib-extraction'); fs.mkdirSync(phase32Dir, { recursive: true }); fs.writeFileSync(path.join(phase32Dir, '32-01-PLAN.md'), '# Plan', 'utf8'); fs.writeFileSync(path.join(phase32Dir, '32-01-SUMMARY.md'), '# Summary', 'utf8'); fs.mkdirSync(path.join(phasesDir, '33-follow-up-implementation'), { recursive: true }); return { planningDir }; } describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3517-')); }); afterEach(() => { cleanup(tmpDir); }); test('completed_phases is derived from ROADMAP, not blindly incremented (idempotency)', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r1 = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r1.success, `first call failed: ${r1.error}`); const stateAfter1 = fs.readFileSync(statePath, 'utf8'); const progress1 = extractFrontmatter(stateAfter1).progress; assert.ok(progress1, 'progress not found in frontmatter after first call'); assert.equal( Number(progress1.completed_phases), 2, `After first call: completed_phases should be 2 (derived from ROADMAP: phases 4 and 5 complete), got ${progress1.completed_phases}`, ); const r2 = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r2.success, `second call failed: ${r2.error}`); const stateAfter2 = fs.readFileSync(statePath, 'utf8'); const progress2 = extractFrontmatter(stateAfter2).progress; assert.ok(progress2, 'progress not found in frontmatter after second call'); assert.equal( Number(progress2.completed_phases), 2, `After second call (same phase): completed_phases must remain 2 (idempotent), got ${progress2.completed_phases}`, ); }); test('frontmatter stopped_at is updated after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const stoppedAt = extractFrontmatter(state).stopped_at; assert.ok(stoppedAt, 'stopped_at not found in frontmatter'); assert.ok( !stoppedAt.includes('05-03-PLAN.md'), `stopped_at should not still say "Completed 05-03-PLAN.md" — got: ${stoppedAt}`, ); assert.ok( stoppedAt.toLowerCase().includes('phase 5') || stoppedAt.toLowerCase().includes('complete'), `stopped_at should reference phase 5 completion, got: ${stoppedAt}`, ); }); test('frontmatter last_updated is refreshed to today after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const raw = extractFrontmatter(state).last_updated; assert.ok(raw, 'last_updated not found in frontmatter'); // Must have been refreshed — not the stale seed value from setupPhase3517Project assert.notEqual( raw, '2026-05-10T08:00:00.000Z', `last_updated must be refreshed, but it is still the stale seed value: ${raw}`, ); // Must parse as a valid ISO timestamp const updatedAt = new Date(raw); assert.ok( !isNaN(updatedAt.getTime()), `last_updated must be a valid ISO timestamp, got: ${raw}`, ); // Date portion must equal today's UTC date — avoids any wall-clock window comparison const todayUtc = new Date().toISOString().slice(0, 10); const updatedDateUtc = updatedAt.toISOString().slice(0, 10); assert.equal( updatedDateUtc, todayUtc, `last_updated date portion must equal today's UTC date (${todayUtc}), got: ${updatedDateUtc}`, ); }); test('frontmatter total_plans is updated from ROADMAP plan counts after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const progress = extractFrontmatter(state).progress; assert.ok(progress && progress.total_plans !== undefined, 'total_plans not found in frontmatter'); const totalPlans = Number(progress.total_plans); assert.ok(Number.isFinite(totalPlans) && totalPlans > 0, `total_plans must be a positive number, got: ${progress.total_plans}`); }); test('frontmatter completed_plans is updated from SUMMARY file count after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const progress = extractFrontmatter(state).progress; assert.ok(progress && progress.completed_plans !== undefined, 'completed_plans not found in frontmatter'); const completedPlans = Number(progress.completed_plans); assert.equal( completedPlans, 10, `completed_plans should be 10 (3 phase-4 summaries + 7 phase-5 summaries), got: ${completedPlans}`, ); }); test('frontmatter percent is recomputed from fresh derived counts', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const progress = extractFrontmatter(state).progress; assert.ok(progress && progress.percent !== undefined, 'percent not found in frontmatter'); assert.equal(Number(progress.percent), 67, `percent should be 67 (2/3 phases), got: ${progress.percent}`); }); test('state frontmatter and numeric phase line reflect next phase after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const progress = extractFrontmatter(state).progress; assert.equal( Number(progress && progress.completed_phases), 2, `completed_phases must be updated in frontmatter, got: ${progress && progress.completed_phases}`, ); const phaseLine = stateExtractField(state, 'Phase'); const { phase: nextPhase } = parsePhaseFromProse(phaseLine); assert.equal( Number(nextPhase), 6, `numeric Phase line should advance to phase 6, got Phase line: ${phaseLine}`, ); }); test('prose-block STATE keeps next phase name without field-miss warnings (#1316)', () => { const { planningDir } = setupPhase1316Project(tmpDir); writePassedVerificationForPhase(tmpDir, '32'); const result = toLegacyResult(runNode([GSD_TOOLS_BIN, 'phase', 'complete', '32'], { cwd: tmpDir, env: process.env, timeoutMs: PHASE_COMPLETE_TIMEOUT_MS, })); assert.strictEqual(result.status, 0, `phase complete failed: ${result.stderr || result.stdout}`); assert.ok( !result.stderr.includes('Current Phase Name'), `phase.complete must not warn about missing Current Phase Name on prose-block STATE.md; stderr:\n${result.stderr}`, ); assert.ok( !result.stderr.includes('Last Activity Description'), `phase.complete must not warn about missing Last Activity Description on prose-block STATE.md; stderr:\n${result.stderr}`, ); const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8'); const currentPhase = extractFrontmatter(state).current_phase; assert.equal( String(currentPhase), '33', `current_phase frontmatter must advance to 33, got: ${currentPhase}`, ); const phaseLine = stateExtractField(state, 'Phase'); const { phase: nextPhase, name: nextPhaseName } = parsePhaseFromProse(phaseLine); assert.equal(Number(nextPhase), 33, `Current Position Phase line must advance to 33; got Phase line: ${phaseLine}`); assert.equal( nextPhaseName, 'Follow Up Implementation', `Current Position Phase line must keep the next phase name; got Phase line: ${phaseLine}`, ); const lastActivity = stateExtractField(state, 'Last activity'); assert.match( lastActivity || '', /^\d{4}-\d{2}-\d{2}\s+—\s+Phase 32 complete/, `Last activity line must use the template em-dash delimiter with narrative; got: ${lastActivity}`, ); }); test('body By Phase table row for completed phase shows correct plan count', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const table = parseMarkdownTable(state); assert.ok(table.ok, `By Phase table must parse; reason: ${table.ok ? '' : table.reason}`); const row = table.value.rows.find((r) => r.Phase.trim() === '5'); assert.ok(row, `By Phase table should have a row for phase 5.\nState:\n${state}`); assert.equal( row.Plans.trim(), '7', `By Phase table row for phase 5 should show 7 summaries, got row: ${JSON.stringify(row)}`, ); }); test('full consistency check: all STATE.md fields are coherent after phase.complete', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); assert.equal(r.data?.state_updated, true, 'state_updated must be true'); const state = fs.readFileSync(statePath, 'utf8'); const fm = extractFrontmatter(state); assert.equal( Number(fm.progress && fm.progress.completed_phases), 2, `completed_phases must be 2 (4 and 5 complete), got: ${fm.progress && fm.progress.completed_phases}`, ); assert.equal( Number(fm.progress && fm.progress.percent), 67, `percent must be 67%, got: ${fm.progress && fm.progress.percent}`, ); const phaseLine = stateExtractField(state, 'Phase'); const { phase: bodyPhase } = parsePhaseFromProse(phaseLine); const hasPhase6 = Number(bodyPhase) === 6 || Number(fm.current_phase) === 6; assert.ok( hasPhase6, `STATE.md must reference Phase 6 as current after completing Phase 5. body Phase line: ${phaseLine}, frontmatter current_phase: ${fm.current_phase}`, ); }); }); // ───────────────────────────────────────────────────────────────────────── // ADR-3408 §8.3 Matrix A (#3469): cmdPhaseComplete now calls the ONE // write-seam composition (syncAndPreserveStateMd) directly instead of // hand-assembling syncStateFrontmatter + applyPostSyncPreservation itself // (Finding 3's re-derivation). Rows A2/A3's general identity claim (the // composition agrees with itself regardless of caller) is covered by the // required fast-check property test in tests/state.test.cjs; this block // covers the ones that need a REAL cmdPhaseComplete run — A6/A7's atomic // 3-file commit, plus a concrete consumer-level demonstration that BOTH // sync (a body-derived field advances) and preservation (an untouched // curated field survives) fire together through the real CLI path. // ───────────────────────────────────────────────────────────────────────── describe('ADR-3408 §8.3 Matrix A: cmdPhaseComplete write-seam composition (#3469)', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3469-a-')); }); afterEach(() => { cleanup(tmpDir); }); // A2/A3 (concrete, consumer-level): the same real cmdPhaseComplete call // both advances a body-derived field (progress.completed_phases, via the // transition) AND restores a CLASSIFIED preserve-when-unchanged field // (`paused_at`) completePhaseCore's transform never touches — proof the // seam applies sync AND `applyPostSyncPreservation`'s policy TOGETHER // through the real adapter, not just the transition alone. test('A2/A3: phase.complete advances the body-derived phase AND restores an untouched preserve-when-unchanged field, in one write', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); // Seed a curated `paused_at` — completePhaseCore never writes the // `## Session` `Paused At` body line, so its pre/post body source is // unchanged (both absent) and the classified preserve-when-unchanged // row must restore this curated value over the sync's empty derive. const before = fs.readFileSync(statePath, 'utf8'); fs.writeFileSync(statePath, before.replace(/^gsd_state_version: 1\.0$/m, 'gsd_state_version: 1.0\npaused_at: "curated pause note — must survive"')); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const state = fs.readFileSync(statePath, 'utf8'); const fm = extractFrontmatter(state); // A2/A3: the transition genuinely advanced the body-derived phase... assert.equal(Number(fm.progress && fm.progress.completed_phases), 2); // ...while `applyPostSyncPreservation`'s classified restore fired in // the SAME write — the composition's preservation stage ran, not just // its sync stage. assert.equal( fm.paused_at, 'curated pause note — must survive', 'a classified preserve-when-unchanged field completePhaseCore never touches must survive the same write', ); }); // A6 (independence): the composition returns content; the adapter's own // atomic 3-file envelope is unaffected — ROADMAP, REQUIREMENTS, and // STATE.md all change together as one unit. test('A6: STATE.md, ROADMAP.md, and REQUIREMENTS.md commit atomically as one unit', () => { setupPhase3517Project(tmpDir); const paths = { state: path.join(tmpDir, '.planning', 'STATE.md'), roadmap: path.join(tmpDir, '.planning', 'ROADMAP.md'), }; const before = { state: fs.readFileSync(paths.state, 'utf8'), roadmap: fs.readFileSync(paths.roadmap, 'utf8'), }; const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); const after = { state: fs.readFileSync(paths.state, 'utf8'), roadmap: fs.readFileSync(paths.roadmap, 'utf8'), }; assert.notStrictEqual(after.state, before.state, 'STATE.md must change'); assert.notStrictEqual(after.roadmap, before.roadmap, 'ROADMAP.md must change'); }); // A7 (IO failure, filesystem-failure category): a mid-commit failure on // the FIRST file in the atomic set (ROADMAP.md) leaves NONE of the three // partially written — including STATE.md, which now flows through the // shared syncAndPreserveStateMd composition before writePlanningFileSet // ever sees it. `t.mock.method` auto-restores at test end — never // chmod 0o000, which root bypasses under Docker/CI. test('A7: a failure writing ROADMAP.md (first in the atomic set) leaves STATE.md and REQUIREMENTS.md untouched', (t) => { setupPhase3517Project(tmpDir); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const before = { roadmap: fs.readFileSync(roadmapPath, 'utf8'), req: fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf8') : null, state: fs.readFileSync(statePath, 'utf8'), }; writePassedVerificationForPhase(tmpDir, '5'); const phaseModule = require('../gsd-core/bin/lib/phase.cjs'); const originalWriteFileSync = fs.writeFileSync; t.mock.method(fs, 'writeFileSync', function injectedRoadmapWriteFailure(target, ...args) { const targetPath = String(target); if (targetPath === roadmapPath || targetPath === `${roadmapPath}.tmp.${process.pid}`) { const err = new Error('injected ROADMAP.md write failure'); err.code = 'EIO'; throw err; } return originalWriteFileSync.call(this, target, ...args); }); assert.throws( () => phaseModule.cmdPhaseComplete(tmpDir, '5', false), /injected ROADMAP\.md write failure/, ); const after = { roadmap: fs.readFileSync(roadmapPath, 'utf8'), req: fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf8') : null, state: fs.readFileSync(statePath, 'utf8'), }; assert.strictEqual(after.roadmap, before.roadmap, 'ROADMAP.md must be unchanged'); assert.strictEqual(after.req, before.req, 'REQUIREMENTS.md must be unchanged'); assert.strictEqual(after.state, before.state, 'STATE.md must be unchanged — none of the three partially written'); }); }); // ───────────────────────────────────────────────────────────────────────── // ADR-3408 §8.5 Matrix B (#3471): cmdPhaseComplete's preservation is now // visible via `preservation_warnings` (D2 — #3374's "warnings: []" // complaint, on the exact command it was filed against). Design: // .gsd/phase/refactor-3471-stale-but-present/40-design.md. Matrix: // .gsd/phase/refactor-3471-stale-but-present/50-test-matrix.md section B. // Reuses setupPhase3517Project/runSdkQuery/writePassedVerificationForPhase // from this same folded block (bug #3517 fixture). // ───────────────────────────────────────────────────────────────────────── describe('ADR-3408 §8.5 Matrix B (#3471): cmdPhaseComplete preservation visibility (D2)', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3471-b-')); }); afterEach(() => { cleanup(tmpDir); }); // B1 — consumer-level (ADR-3180 Decision 4(c)): a curated field the // transition never touches (its body source is unchanged this write) // must be named in `preservation_warnings`, closing #3374's exact // "warnings: []" silence for the command it was filed against. test('B1: cmdPhaseComplete names the field it preserved in preservation_warnings', () => { setupPhase3517Project(tmpDir); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const before = fs.readFileSync(statePath, 'utf8'); fs.writeFileSync(statePath, before.replace(/^gsd_state_version: 1\.0$/m, 'gsd_state_version: 1.0\npaused_at: "curated pause note — must survive"')); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); assert.ok(Array.isArray(r.data.preservation_warnings), 'preservation_warnings must be an array'); assert.deepStrictEqual( r.data.preservation_warnings, [{ field: 'paused_at', reason: 'preserved-over-disagreeing-derived' }], ); }); // B2 — negative: no false alarm. When nothing is preserved (no // disagreeing curated field), `preservation_warnings` must be empty, not // populated with a phantom entry. test('B2: cmdPhaseComplete emits an empty preservation_warnings when nothing was preserved', () => { setupPhase3517Project(tmpDir); const r = runSdkQuery(['phase.complete', '5'], tmpDir); assert.ok(r.success, `call failed: ${r.error}`); assert.deepStrictEqual(r.data.preservation_warnings, []); }); }); } // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3601 — phase remove preserves peer-depth decimal sections // (consolidated from tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { process.env.GSD_TEST_MODE = '1'; function writeRoadmapForRemove(tmpDir, body) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); } function writeStateForRemove(tmpDir, version) { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `---\nmilestone: ${version}\n---\n`, ); } function ensurePhaseDir(tmpDir, name) { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true }); } function getPhase(tmpDir, phaseNum) { const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir); if (!r.success) return { found: false, error: r.error }; return JSON.parse(r.output); } describe('bug #3601: phase remove preserves peer-depth decimal sections', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('bug-3601-'); }); afterEach(() => { cleanup(tmpDir); }); test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 2: Parent', '**Goal:** RemoveMeGoal', '', '### Phase 2.1: Follow-up', '**Goal:** PreserveDecimalGoal', '', '### Phase 3: Trailing', '**Goal:** PreserveTrailingGoal', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '02-parent'); ensurePhaseDir(tmpDir, '02.1-follow-up'); ensurePhaseDir(tmpDir, '03-trailing'); const r = runGsdTools(['phase', 'remove', '2'], tmpDir); assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); const decimal = getPhase(tmpDir, '2.1'); assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2'); assert.strictEqual(decimal.phase_name, 'Follow-up'); assert.strictEqual(decimal.goal, 'PreserveDecimalGoal'); const renumbered = getPhase(tmpDir, '2'); assert.strictEqual(renumbered.found, true); assert.strictEqual(renumbered.phase_name, 'Trailing'); assert.strictEqual( renumbered.goal, 'PreserveTrailingGoal', 'Phase 3 → Phase 2 renumber did not carry the right section content', ); const parentLookup = getPhase(tmpDir, '3'); assert.notStrictEqual( parentLookup.goal, 'RemoveMeGoal', 'removed parent goal reappeared under a phase header', ); }); test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0 - Test', '', '### Phase 5: Parent', '**Goal:** RemoveParent', '', '### Phase 5.1: First child', '**Goal:** ChildAGoal', '', '### Phase 5.2: Second child', '**Goal:** ChildBGoal', '', '### Phase 6: Tail', '**Goal:** TailGoal', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '05-parent'); ensurePhaseDir(tmpDir, '05.1-first-child'); ensurePhaseDir(tmpDir, '05.2-second-child'); ensurePhaseDir(tmpDir, '06-tail'); const r = runGsdTools(['phase', 'remove', '5'], tmpDir); assert.ok(r.success); const decimalA = getPhase(tmpDir, '5.1'); assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted'); assert.strictEqual(decimalA.goal, 'ChildAGoal'); const decimalB = getPhase(tmpDir, '5.2'); assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted'); assert.strictEqual(decimalB.goal, 'ChildBGoal'); const tail = getPhase(tmpDir, '5'); assert.strictEqual(tail.found, true); assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired'); }); }); // ───────────────────────────────────────────────────────────────────────────── // regression: bug #3602 — phase remove renumbers slugged plan references // (consolidated from tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs) // ───────────────────────────────────────────────────────────────────────────── function ensurePlanFile(tmpDir, phaseDirName, planName) { const p = path.join(tmpDir, '.planning', 'phases', phaseDirName, planName); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, '# Plan\n'); } describe('bug #3602: phase remove renumbers slugged plan references in ROADMAP', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('bug-3602-'); }); afterEach(() => { cleanup(tmpDir); }); test('slugged PLAN reference (07-01-cherry-pick-foundation-PLAN.md) is renumbered to 06-01-…', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0', '', '### Phase 6: Old Work', '**Goal:** RemoveThisGoal', '', '### Phase 7: New Work', '**Goal:** Plans: 07-01-cherry-pick-foundation-PLAN.md and 07-02-finish-it-SUMMARY.md', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '06-old'); ensurePhaseDir(tmpDir, '07-new'); ensurePlanFile(tmpDir, '07-new', '07-01-cherry-pick-foundation-PLAN.md'); ensurePlanFile(tmpDir, '07-new', '07-02-finish-it-SUMMARY.md'); const r = runGsdTools(['phase', 'remove', '6'], tmpDir); assert.ok(r.success, `phase remove failed: ${r.error || r.output}`); const phase6 = getPhase(tmpDir, '6'); assert.strictEqual(phase6.found, true); assert.strictEqual(phase6.phase_name, 'New Work'); assert.ok( phase6.goal.includes('06-01-cherry-pick-foundation-PLAN.md'), `Plans reference for slugged PLAN was not renumbered. Goal: ${phase6.goal}`, ); assert.ok( phase6.goal.includes('06-02-finish-it-SUMMARY.md'), `Plans reference for slugged SUMMARY was not renumbered. Goal: ${phase6.goal}`, ); assert.ok( !phase6.goal.includes('07-01'), `stale 07-01 prefix remains in ROADMAP. Goal: ${phase6.goal}`, ); assert.ok( !phase6.goal.includes('07-02'), `stale 07-02 prefix remains in ROADMAP. Goal: ${phase6.goal}`, ); }); test('compact PLAN/SUMMARY reference (07-01-PLAN.md) still renumbers (#3601 contract preserved)', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0', '', '### Phase 6: Old', '**Goal:** RemoveGoal', '', '### Phase 7: New', '**Goal:** Plans: 07-01-PLAN.md and 07-02-SUMMARY.md', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '06-old'); ensurePhaseDir(tmpDir, '07-new'); ensurePlanFile(tmpDir, '07-new', '07-01-PLAN.md'); ensurePlanFile(tmpDir, '07-new', '07-02-SUMMARY.md'); const r = runGsdTools(['phase', 'remove', '6'], tmpDir); assert.ok(r.success); const phase6 = getPhase(tmpDir, '6'); assert.strictEqual(phase6.found, true); assert.ok(phase6.goal.includes('06-01-PLAN.md')); assert.ok(phase6.goal.includes('06-02-SUMMARY.md')); assert.ok(!phase6.goal.includes('07-01-PLAN.md')); assert.ok(!phase6.goal.includes('07-02-SUMMARY.md')); }); test('does NOT renumber values that look like phase-plan tokens but are not (e.g. 2026-01-01 dates)', () => { writeStateForRemove(tmpDir, 'v1.0.0'); writeRoadmapForRemove( tmpDir, [ '# Roadmap', '', '## Current Milestone: v1.0.0', '', '### Phase 6: Old', '**Goal:** RemoveGoal', '', '### Phase 7: Date safety', '**Goal:** Created 2026-01-01 and tagged v1-2-3 — must not renumber', '', ].join('\n'), ); ensurePhaseDir(tmpDir, '06-old'); ensurePhaseDir(tmpDir, '07-new'); const r = runGsdTools(['phase', 'remove', '6'], tmpDir); assert.ok(r.success); const phase6 = getPhase(tmpDir, '6'); assert.strictEqual(phase6.found, true); assert.ok( phase6.goal.includes('2026-01-01'), `ISO date 2026-01-01 was wrongly modified. Goal: ${phase6.goal}`, ); assert.ok( phase6.goal.includes('v1-2-3'), `version-tag v1-2-3 was wrongly modified. Goal: ${phase6.goal}`, ); }); }); } // ───────────────────────────────────────────────────────────────────────────── // feature: phase complete auto-prune (#2087) // (consolidated from tests/phase-complete-auto-prune.test.cjs) // ───────────────────────────────────────────────────────────────────────────── { function writeConfigForAutoPrune(tmpDir, config) { fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2)); } function writeStateMdForAutoPrune(tmpDir, content) { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content); } function readStateMdForAutoPrune(tmpDir) { return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); } function writeRoadmapForAutoPrune(tmpDir, content) { fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); } function setupPhaseForAutoPrune(tmpDir, phaseNum, planCount) { const phasesDir = path.join(tmpDir, '.planning', 'phases'); const phaseDir = path.join(phasesDir, `${String(phaseNum).padStart(2, '0')}-test-phase`); fs.mkdirSync(phaseDir, { recursive: true }); for (let i = 1; i <= planCount; i++) { const planId = `${String(phaseNum).padStart(2, '0')}-${String(i).padStart(2, '0')}`; fs.writeFileSync(path.join(phaseDir, `${planId}-PLAN.md`), `# Plan ${planId}\n`); fs.writeFileSync(path.join(phaseDir, `${planId}-SUMMARY.md`), `# Summary ${planId}\n`); } } describe('phase complete auto-prune (#2087)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('prunes STATE.md automatically when auto_prune_state is true', () => { writeConfigForAutoPrune(tmpDir, { workflow: { auto_prune_state: true }, }); writeStateMdForAutoPrune(tmpDir, [ '# Session State', '', '**Current Phase:** 6', '**Status:** Executing', '', '## Decisions', '', '- [Phase 1]: Old decision from phase 1', '- [Phase 2]: Old decision from phase 2', '- [Phase 5]: Recent decision', '- [Phase 6]: Current decision', '', ].join('\n')); writeRoadmapForAutoPrune(tmpDir, [ '# Roadmap', '', '## Phase 6: Test Phase', '', '**Plans:** 0/2', '', ].join('\n')); setupPhaseForAutoPrune(tmpDir, 6, 2); const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/); assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/); assert.match(newState, /\[Phase 5\]: Recent decision/); assert.match(newState, /\[Phase 6\]: Current decision/); }); test('does NOT prune when auto_prune_state is false (default)', () => { writeConfigForAutoPrune(tmpDir, { workflow: { auto_prune_state: false }, }); writeStateMdForAutoPrune(tmpDir, [ '# Session State', '', '**Current Phase:** 6', '**Status:** Executing', '', '## Decisions', '', '- [Phase 1]: Old decision from phase 1', '- [Phase 5]: Recent decision', '- [Phase 6]: Current decision', '', ].join('\n')); writeRoadmapForAutoPrune(tmpDir, [ '# Roadmap', '', '## Phase 6: Test Phase', '', '**Plans:** 0/2', '', ].join('\n')); setupPhaseForAutoPrune(tmpDir, 6, 2); const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); assert.match(newState, /\[Phase 1\]: Old decision/); }); test('does NOT prune when auto_prune_state is absent from config', () => { writeConfigForAutoPrune(tmpDir, { workflow: {}, }); writeStateMdForAutoPrune(tmpDir, [ '# Session State', '', '**Current Phase:** 6', '**Status:** Executing', '', '## Decisions', '', '- [Phase 1]: Old decision from phase 1', '- [Phase 6]: Current decision', '', ].join('\n')); writeRoadmapForAutoPrune(tmpDir, [ '# Roadmap', '', '## Phase 6: Test Phase', '', '**Plans:** 0/2', '', ].join('\n')); setupPhaseForAutoPrune(tmpDir, 6, 2); const result = runVerifiedPhaseComplete('phase complete 6', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const newState = readStateMdForAutoPrune(tmpDir); assert.match(newState, /\[Phase 1\]: Old decision/); }); }); } // ───────────────────────────────────────────────────────────────────────────── // bug #1229: phase.add bullet-only phase collision // ───────────────────────────────────────────────────────────────────────────── // Count canonical Phase N entries in roadmap content (header or bullet form). // Only counts "### Phase N:" headers and "- [ ] **Phase N:" bullet entries. // Does NOT count references like "**Depends on:** Phase N". function countBug1229PhaseNumber(roadmapContent, n) { let count = 0; const headerRe = new RegExp('^#{2,4}\\s*Phase\\s+' + n + '[A-Z]?(?:\\.\\d+)*:', 'gim'); const bulletRe = new RegExp( '^[ \\t]*-[ \\t]*\\[[^\\]]*\\][ \\t]*\\*{0,2}Phase[ \\t]+' + n + '(?=[:.\\ \\t*]|$)', 'gim', ); const headerMatches = roadmapContent.match(headerRe); const bulletMatches = roadmapContent.match(bulletRe); if (headerMatches) count += headerMatches.length; if (bulletMatches) count += bulletMatches.length; return count; } describe('bug #1229: phase.add must count bullet-only phases to avoid number collision', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('bullet-only Phase 11 is counted: next add gets Phase 12, not 11', () => { // ROADMAP has Phases 1-3 as full sections and Phase 11 as bullet-only. // Before the fix, maxPhase resolved to 3 (header scan) and phase.add // silently produced Phase 4, then on a second add would produce Phase 11 // — or if headers went to 10, it would produce Phase 11 colliding with // the existing bullet. const roadmap = [ '# Roadmap v1.0', '', '## Phases', '', '- [ ] **Phase 1: Foundation**', '- [ ] **Phase 2: Core**', '- [x] **Phase 3: Done**', '- [ ] **Phase 11: Communications / Zoho Sync**', '', '### Phase 1: Foundation', '', '**Goal:** Build foundations', '', '### Phase 2: Core', '', '**Goal:** Core work', '', '### Phase 3: Done', '', '**Goal:** Completed work', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); // Create disk dirs for phases 1, 2, 3 (not 11 -- that is bullet-only) fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-core'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-done'), { recursive: true }); const result = runGsdTools('phase add New Feature', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 12, `Expected phase 12 (bullet-only Phase 11 must be counted), got ${output.phase_number}`, ); // Verify no duplicate Phase 11 written const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); const phase11Count = countBug1229PhaseNumber(updatedRoadmap, 11); assert.ok( phase11Count === 1, `ROADMAP must have exactly 1 occurrence of Phase 11 (no duplicate), found ${phase11Count}`, ); // Verify Phase 12 was written assert.ok( updatedRoadmap.includes('### Phase 12:'), 'ROADMAP must contain new ### Phase 12: entry', ); // Verify directory was created at 12, not 11 assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-new-feature')), 'phases/12-new-feature directory must be created', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-new-feature')), 'phases/11-new-feature must NOT be created (collision guard)', ); }); test('[x] checkbox variant bullet phase is counted', () => { // Phase 5 exists only as a [x] bullet (completed, no dir, no header) const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: API', '', '**Goal:** Build', '', '### Phase 3: UI', '', '**Goal:** Interfaces', '', '### Phase 4: Deploy', '', '**Goal:** Ship it', '', '- [x] **Phase 5: Post-launch Cleanup**', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Follow-up Work', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 6, `Expected phase 6 ([x] bullet-only Phase 5 must be counted), got ${output.phase_number}`, ); }); test('[~] checkbox variant bullet phase is counted', () => { // Phase 7 exists only as a [~] bullet (in-progress, no dir, no header) const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '- [~] **Phase 7: Partial Work**', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Next Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 8, `Expected phase 8 ([~] bullet-only Phase 7 must be counted), got ${output.phase_number}`, ); }); test('baseline: no bullet-only phases -- existing behavior preserved', () => { const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: API', '', '**Goal:** Build', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Third Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 3, `Expected phase 3 (normal sequential add), got ${output.phase_number}`, ); }); test('bullet without ** bold markers is counted', () => { // Phase 6 as plain bullet without ** markdown bold const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: Core', '', '**Goal:** Core', '', '- [ ] Phase 6: Plain bullet no bold', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Another Phase', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 7, `Expected phase 7 (plain-bullet Phase 6 must be counted), got ${output.phase_number}`, ); }); test('titleless bold bullet "- [ ] **Phase 11**" is counted: next add gets Phase 12', () => { // Regression for the adversarial-review finding: the original bulletPattern // required a colon or whitespace after the digits, so "- [ ] **Phase 11**" // (bold-close immediately after the number) was silently skipped and phase.add // would assign Phase 11 again — the exact collision class bug #1229 fixes. const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: Core', '', '**Goal:** Core', '', '- [ ] **Phase 11**', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add Titleless Bold Follow-up', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 12, `Expected phase 12 (titleless bold bullet "**Phase 11**" must be counted), got ${output.phase_number}`, ); const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( updatedRoadmap.includes('### Phase 12:'), 'ROADMAP must contain new ### Phase 12: entry', ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-titleless-bold-follow-up')), 'phases/12-titleless-bold-follow-up directory must be created', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-titleless-bold-follow-up')), 'phases/11-titleless-bold-follow-up must NOT be created (collision guard)', ); }); test('EOL bullet "- [ ] Phase 11" (no title, no bold) is counted: next add gets Phase 12', () => { // Regression: "- [ ] Phase 11" at end-of-line was not matched by the original // pattern whose trailing [:\s] requires at least one character after the digits. const roadmap = [ '# Roadmap v1.0', '', '### Phase 1: Foundation', '', '**Goal:** Setup', '', '### Phase 2: Core', '', '**Goal:** Core', '', '- [ ] Phase 11', '', '---', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const result = runGsdTools('phase add EOL Follow-up', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.phase_number, 12, `Expected phase 12 (EOL bullet "Phase 11" must be counted), got ${output.phase_number}`, ); const updatedRoadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); assert.ok( updatedRoadmap.includes('### Phase 12:'), 'ROADMAP must contain new ### Phase 12: entry', ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '12-eol-follow-up')), 'phases/12-eol-follow-up directory must be created', ); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'phases', '11-eol-follow-up')), 'phases/11-eol-follow-up must NOT be created (collision guard)', ); }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/247-phase-uat-passed.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:247-phase-uat-passed (consolidation epic #1969 B2 #1971)", () => { 'use strict'; /** * Integration tests for `phase uat-passed ` CLI command. * Issue #247 — phase uat-passed predicate * * Tests the full dispatch path: gsd-tools → phase-command-router → phase.cmdPhaseUatPassed */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); // ─── Helpers ────────────────────────────────────────────────────────────────── /** * Set up a minimal project with a phase directory and ROADMAP so that * findPhaseInternal(cwd, phaseNum) can resolve it. * Returns { tmpDir, phaseDir }. */ function setupProject(phaseSlug = '01-feature') { const tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 1: Feature', '', '### Phase 1: Feature', '**Goal:** Build feature', '**Plans:** 1 plans', '', ].join('\n'), ); const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug); fs.mkdirSync(phaseDir, { recursive: true }); return { tmpDir, phaseDir }; } function writeUatFile(phaseDir, filename, content) { fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8'); } function setMtime(filePath, time) { fs.utimesSync(filePath, time, time); } function makePassingUat() { return [ '---', 'status: passed', '---', '', '# UAT Results', '', '### 1. Login works', 'expected: User logs in successfully', 'result: passed', '', ].join('\n'); } function makePendingUat() { return [ '---', 'status: partial', '---', '', '# UAT Results', '', '### 1. Login works', 'expected: User logs in successfully', 'result: passed', '', '### 2. Logout works', 'expected: User logs out successfully', 'result: pending', '', ].join('\n'); } function makeFencedFalsePositiveUat() { // Only "result: passed" lines are inside a fenced block. // The real test has result: pending → should evaluate to passed:false. return [ '---', 'status: partial', '---', '', '# UAT Results', '', '## Example (do not run)', '```', '### 1. Test', 'expected: Example', 'result: passed', '```', '', '### 1. Real Test', 'expected: The thing works', 'result: pending', '', ].join('\n'); } // ─── Basic pass/fail cases ───────────────────────────────────────────────────── describe('phase uat-passed — basic pass/fail', () => { let tmpDir; let phaseDir; beforeEach(() => { ({ tmpDir, phaseDir } = setupProject('01-feature')); }); afterEach(() => { cleanup(tmpDir); }); test('passing UAT → passed:true with correct JSON shape', () => { writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat()); const result = runGsdTools('phase uat-passed 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, true); assert.strictEqual(out.phase, '1'); assert.ok(Array.isArray(out.uat_files), 'uat_files must be an array'); assert.ok(Array.isArray(out.verification_files), 'verification_files must be an array'); assert.ok(Array.isArray(out.checks), 'checks must be an array'); assert.ok(Array.isArray(out.blockers), 'blockers must be an array'); assert.ok(out.policy && typeof out.policy.require_verification === 'boolean', 'policy.require_verification must be a boolean'); assert.strictEqual(typeof out.no_uat_artifacts, 'boolean', 'no_uat_artifacts must be a boolean'); assert.strictEqual(out.no_uat_artifacts, false, 'no_uat_artifacts must be false when checks exist'); assert.strictEqual(out.blockers.length, 0); }); test('pending UAT → passed:false', () => { writeUatFile(phaseDir, 'feature-UAT.md', makePendingUat()); const result = runGsdTools('phase uat-passed 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, false); assert.strictEqual(out.phase, '1'); assert.ok(out.blockers.length > 0, 'Should have blockers for pending test'); }); test('false-positive only (fenced block) → passed:false', () => { writeUatFile(phaseDir, 'feature-UAT.md', makeFencedFalsePositiveUat()); const result = runGsdTools('phase uat-passed 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, false, 'result:passed inside a fenced block must not flip the predicate to passed'); }); test('no UAT files → passed:false + no_uat_artifacts:true (fail-closed, no vacuous pass)', () => { // Phase directory exists but has no UAT files — fail-closed: absence is NOT a pass const result = runGsdTools('phase uat-passed 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, false, 'Phase with no UAT files must NOT vacuously pass — fail-closed predicate'); assert.strictEqual(out.no_uat_artifacts, true, 'no_uat_artifacts must be true when no UAT items found'); assert.deepStrictEqual(out.uat_files, []); }); }); // ─── --require-verification flag ────────────────────────────────────────────── describe('phase uat-passed — --require-verification flag', () => { let tmpDir; let phaseDir; beforeEach(() => { ({ tmpDir, phaseDir } = setupProject('01-feature')); }); afterEach(() => { cleanup(tmpDir); }); test('--require-verification with no verification file → passed:false', () => { writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat()); const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, false, 'require-verification with no verification file should fail'); assert.strictEqual(out.policy.require_verification, true); assert.ok(out.blockers.some(b => /verification required/i.test(b)), `Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`); }); test('--require-verification with passing verification → passed:true', () => { writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat()); writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.'); const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, true); assert.strictEqual(out.policy.require_verification, true); }); test('--require-verification with stale passed verification → passed:false', () => { writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat()); const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md'); const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md'); writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.'); writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n'); const now = new Date(); setMtime(verificationPath, new Date(now.getTime() - 60_000)); setMtime(summaryPath, now); const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, false); assert.ok( out.blockers.some(b => /verification status=stale/i.test(b)), `Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`, ); }); test('--require-verification with non-canonical complete verification → passed:false', () => { writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat()); writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.'); const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.passed, false); assert.ok(out.blockers.some(b => /verification required/i.test(b)), `Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`); }); }); // ─── Error cases ────────────────────────────────────────────────────────────── describe('phase uat-passed — error cases', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); // Write a minimal ROADMAP so phase 1 exists fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), [ '# Roadmap', '', '### Phase 1: Feature', '**Goal:** Build feature', '', ].join('\n'), ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-feature'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); test('missing phase number → error message', () => { const result = runGsdTools('phase uat-passed', tmpDir); assert.ok(!result.success, 'Should fail with no phase number'); assert.ok( result.error.includes('phase number required') || result.error.includes('Available:'), `Expected phase-number-required error, got: ${result.error}`, ); }); test('unknown phase number → error message', () => { const result = runGsdTools('phase uat-passed 99', tmpDir); assert.ok(!result.success, 'Should fail for unknown phase'); assert.ok( result.error.includes('not found') || result.error.includes('99'), `Expected not-found error, got: ${result.error}`, ); }); test('unknown flag (typo --require-verifcation) → InvalidArgs error, not silent pass', () => { const result = runGsdTools('phase uat-passed 1 --require-verifcation', tmpDir); assert.ok(!result.success, 'Unknown flag must cause an error, not silently pass'); assert.ok( result.error.includes('--require-verifcation') || result.error.includes('does not support') || result.error.includes('invalid'), `Expected unknown-flag error, got: ${result.error}`, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2769-requirements-header-variants.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2769-requirements-header-variants (consolidation epic #1969 B2 #1971)", () => { /** * Regression tests for issue #2769 * * The Requirements header in ROADMAP.md phase blocks renders identically in * markdown for three textually distinct forms: * * **Requirements:** colon INSIDE bold delimiters * **Requirements**: colon OUTSIDE bold delimiters * **Requirements** : space-then-colon outside bold * * Two parsers in the codebase used opposing strict regexes — one only * matched the outside-colon form (init.cjs / init.ts), the other only the * inside-colon form (phase.cjs `cmdPhaseComplete` REQUIREMENTS.md * traceability sweep). Both must accept all three variants so phase * metadata propagation is robust to authoring style. * * Tests for the init query side live in `tests/init.test.cjs` (parameterized * over the three variants). This file exercises the inverse bug in * `phase complete`: the REQUIREMENTS.md checkbox must flip when ROADMAP * uses the outside-colon form, which previously was silently skipped. */ 'use strict'; 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 { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), ['---', 'current_phase: 1', 'status: executing', '---', '# State', ''].join('\n'), ); }); afterEach(() => { cleanup(tmpDir); }); const headerVariants = [ { name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** REQ-001' }, { name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: REQ-001' }, { name: 'space before colon (**Requirements** :)', header: '**Requirements** : REQ-001' }, ]; for (const variant of headerVariants) { test(`flips REQ-001 checkbox in REQUIREMENTS.md when ROADMAP uses ${variant.name}`, () => { const phasesDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phasesDir, { recursive: true }); fs.writeFileSync( path.join(phasesDir, '01-1-PLAN.md'), ['---', 'phase: 1', 'plan: 1', '---', '# Plan 1', ''].join('\n'), ); fs.writeFileSync( path.join(phasesDir, '01-1-SUMMARY.md'), ['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'), ); fs.writeFileSync( path.join(phasesDir, '01-VERIFICATION.md'), ['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'), ); const roadmap = [ '# Roadmap', '', '### Phase 1: Foundation', '', '**Goal:** Build core', variant.header, '**Plans:** 1 plans', '', 'Plans:', '- [x] 01-1-PLAN.md', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 1. Foundation | 0/1 | Pending | - |', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); const requirements = [ '# Requirements', '', '## Functional Requirements', '', '- [ ] **REQ-001**: Core data model', '', '## Traceability', '', '| REQ-ID | Phase | Status |', '|--------|-------|--------|', '| REQ-001 | 1 | Pending |', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), requirements); const result = runGsdTools(['phase', 'complete', '1'], tmpDir); assert.ok(result.success, `phase complete failed: ${result.error}`); const updated = fs.readFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8', ); assert.match( updated, /-\s*\[x\]\s*\*\*REQ-001\*\*/, `REQ-001 checkbox must be flipped to [x] when ROADMAP header is "${variant.header}". Got:\n${updated}`, ); assert.match( updated, /\|\s*REQ-001\s*\|\s*1\s*\|\s*Complete\s*\|/, `Traceability row for REQ-001 must be marked Complete. Got:\n${updated}`, ); }); } }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3537-padded-id-against-unpadded-roadmap (consolidation epic #1969 B2 #1971)", () => { /** * Regression tests for bug #3537 * * Phase state verbs must match a canonical phase id against ROADMAP.md prose * regardless of zero-padding on either side: the skills pass the padded form * (`02.7`) after resolving the phase directory, but human-authored ROADMAP * prose is conventionally un-padded (`### Phase 2.7:`, `- [ ] **Phase 2.7:**`). * * v1.42.1 added `phaseMarkdownRegexSource()` which renders `0*<...>` * — padding-tolerant on both sides — but wired it into only 1 of 8 call sites. * The other 7 used raw `escapeRegex(phaseNum)` or `0*${escapeRegex(...)}` * (tolerated extra padding, not missing), so passing the padded form silently * no-op'd and the verbs returned success while ROADMAP.md was unchanged. * * Parity assertion (per CONTEXT.md DEFECT.GENERATIVE-FIX): for each verb, * running with the padded form must produce the same ROADMAP.md as running * with the un-padded form against an identical fixture. Per-site fixes * without a parity test let the next call-site drift back undetected. */ 'use strict'; const { describe, test } = 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 { execFileSync } = require('node:child_process'); const { cleanup } = require('./helpers.cjs'); const gsdTools = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); function run(args, cwd) { try { return { stdout: execFileSync('node', [gsdTools, ...args], { cwd, timeout: 15000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }), ok: true, }; } catch (e) { return { stdout: (e.stdout && e.stdout.toString()) || '', stderr: (e.stderr && e.stderr.toString()) || '', ok: false, code: e.status, }; } } /** * Build a planning fixture with project_code='CK', padded phase directory * (`CK-02.7-meta-lead-ads/`), and un-padded ROADMAP prose (`Phase 2.7`). * This mirrors the reporter's environment in #3537 exactly. */ function setupFixture(tmpDir, opts = {}) { const { projectCode = 'CK', paddedId = '02.7', unpaddedId = '2.7', extraPhases = [], } = opts; const planningDir = path.join(tmpDir, '.planning'); fs.mkdirSync(planningDir, { recursive: true }); fs.writeFileSync( path.join(planningDir, 'config.json'), JSON.stringify({ project_code: projectCode }) ); fs.writeFileSync( path.join(planningDir, 'STATE.md'), `---\ncurrent_phase: ${unpaddedId}\nstatus: executing\n---\n# State\n` ); // Padded phase directory with one plan + matching summary so the phase // is "complete" for phase-complete and update-plan-progress verbs. const phaseDirName = `${projectCode}-${paddedId}-meta-lead-ads`; const phaseDir = path.join(planningDir, 'phases', phaseDirName); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, `${paddedId}-01-PLAN.md`), `---\nphase: ${unpaddedId}\nplan: 1\nwave: 1\n---\n# Plan 1\n` ); fs.writeFileSync( path.join(phaseDir, `${paddedId}-01-SUMMARY.md`), '---\nstatus: complete\n---\n# Summary\nDone.' ); fs.writeFileSync( path.join(phaseDir, `${paddedId}-VERIFICATION.md`), '---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n' ); const extra = extraPhases .map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`) .join('\n'); const roadmap = [ '# Roadmap', '', '## v1.0 Milestone', '', `- [ ] **Phase ${unpaddedId}: Meta Lead Ads**`, extra, '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', `| ${unpaddedId} Meta Lead Ads | 0/1 | Planned | - |`, '', `### Phase ${unpaddedId}: Meta Lead Ads`, '', '**Goal:** ship the thing', '**Plans:** 0 plans', '', 'Plans:', `- [ ] ${paddedId}-01-PLAN.md`, '', ...extraPhases.flatMap((p) => [ `### Phase ${p.id}: ${p.name}`, '', '**Goal:** stub', '**Plans:** 0 plans', '', 'Plans:', `- [ ] ${p.id}-01-PLAN.md`, '', ]), ] .filter((l) => l !== '') .join('\n') + '\n'; fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); return { planningDir, roadmapPath: path.join(planningDir, 'ROADMAP.md'), phaseDir, }; } /** * Run a verb in two parallel fixtures — one passing the padded form, one * passing the un-padded form — then compare the resulting ROADMAP.md bytes. * Any divergence means the verb's regex did not tolerate padding on at least * one side. */ function expectParity({ verbWithPadded, verbWithUnpadded, fixtureOpts }) { const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-A-')); const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-B-')); try { const a = setupFixture(tmpA, fixtureOpts); const b = setupFixture(tmpB, fixtureOpts); const ra = verbWithPadded(tmpA); const rb = verbWithUnpadded(tmpB); const aRoadmap = fs.readFileSync(a.roadmapPath, 'utf-8'); const bRoadmap = fs.readFileSync(b.roadmapPath, 'utf-8'); return { aRoadmap, bRoadmap, ra, rb }; } finally { cleanup(tmpA); cleanup(tmpB); } } describe('bug #3537: phase verbs accept padded ids against un-padded ROADMAP prose', () => { test('phase complete: padded 02.7 and un-padded 2.7 produce identical ROADMAP', () => { const { aRoadmap, bRoadmap } = expectParity({ fixtureOpts: {}, verbWithPadded: (cwd) => run(['phase', 'complete', '02.7'], cwd), verbWithUnpadded: (cwd) => run(['phase', 'complete', '2.7'], cwd), }); assert.equal( aRoadmap, bRoadmap, 'padded `02.7` must mutate ROADMAP identically to un-padded `2.7`' ); // And the canonical mutation must have actually happened (otherwise // both forms could be silently no-op'ing and still produce identical // output — a vacuous parity pass). assert.match( aRoadmap, /- \[x\] \*\*Phase 2\.7:/, 'overview checkbox should be flipped under both invocations' ); }); test('roadmap get-phase: padded 02.7 returns the same section as un-padded 2.7', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-get-')); try { setupFixture(tmp, {}); const padded = run(['roadmap', 'get-phase', '02.7', '--raw'], tmp); const unpadded = run(['roadmap', 'get-phase', '2.7', '--raw'], tmp); assert.equal( padded.stdout, unpadded.stdout, 'padded and un-padded ids must return identical sections' ); // Non-vacuous guard: both forms must have actually returned a section // (the bug we're fixing was that the padded form returned an empty // string while reporting success). assert.ok( padded.stdout.trim().length > 0, 'verb must return non-empty section under both invocations' ); } finally { cleanup(tmp); } }); test('phase next-decimal: padded 02 finds decimals in un-padded ROADMAP', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-nd-')); try { setupFixture(tmp, { paddedId: '02.7', unpaddedId: '2.7', }); // Padded base `02` must discover the existing decimal `2.7` from the // un-padded heading and propose `2.8` (or higher) as next. const padded = run(['phase', 'next-decimal', '02', '--raw'], tmp); const unpadded = run(['phase', 'next-decimal', '2', '--raw'], tmp); assert.equal( padded.stdout, unpadded.stdout, 'next-decimal must produce identical JSON for padded and un-padded base' ); // Sanity: the existing 2.7 must be reflected. If the prose-scan regex // silently failed to match `Phase 2.7`, the result would skip 2.7 and // wrongly propose 2.1 as next. `phase next-decimal --raw` emits the // next id as plain text (`02.8`), so trimmed string equality is the // typed assertion shape (no raw-text regex matching — lint policy). const nextDecimalPadded = padded.stdout.trim(); assert.notEqual( nextDecimalPadded, '02.1', 'must not propose 02.1 when 2.7 already exists in ROADMAP' ); assert.notEqual( nextDecimalPadded, '2.1', 'must not propose 2.1 when 2.7 already exists in ROADMAP' ); } finally { cleanup(tmp); } }); test('phase insert: padded base 02 finds anchor in un-padded ROADMAP', () => { const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-A-')); const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-B-')); try { // Use a phase 2 (no decimal) base so insert proposes 2.1. const optsA = { paddedId: '02', unpaddedId: '2', }; setupFixture(tmpA, optsA); setupFixture(tmpB, optsA); const padded = run(['phase', 'insert', '02', 'urgent extension'], tmpA); const unpadded = run(['phase', 'insert', '2', 'urgent extension'], tmpB); // Both invocations should succeed (exit 0) — passing the padded base // against un-padded prose used to error "Phase 02 not found". assert.ok( padded.ok, `padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}` ); assert.ok( unpadded.ok, `un-padded form must succeed, got code=${unpadded.code}` ); const aRoadmap = fs.readFileSync( path.join(tmpA, '.planning', 'ROADMAP.md'), 'utf-8' ); // The new header may be rendered as `Phase 02.1` or `Phase 2.1` // (normalizePhaseName pads to 2 digits today; that is pre-existing // behavior, not the subject of #3537). The critical assertion for // this verb is "padded form found the anchor and the insertion // happened" — full byte-parity is gated by an unrelated `Depends on: // Phase ${afterPhase}` echo bug that lies outside #3537's scope. assert.match( aRoadmap, /### Phase 0?2\.1: urgent extension/, 'padded form must insert the new decimal phase header' ); // Reference `tmpB` to ensure cleanup runs and keep it alive in the // closure — also a smoke-check that the un-padded sibling did not // crash mid-run. assert.ok(fs.existsSync(path.join(tmpB, '.planning', 'ROADMAP.md'))); } finally { cleanup(tmpA); cleanup(tmpB); } }); test('roadmap annotate-dependencies: padded 02.7 finds phase section', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann-')); try { const { roadmapPath } = setupFixture(tmp, {}); const before = fs.readFileSync(roadmapPath, 'utf-8'); const padded = run( ['roadmap', 'annotate-dependencies', '02.7'], tmp ); assert.ok( padded.ok, `padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}` ); const after = fs.readFileSync(roadmapPath, 'utf-8'); // The annotation may be a no-op if there's only one wave and no // cross-cutting truths, but the verb must have reached the phase // section. Confirm by running parity against un-padded form on a // separate fixture and asserting equality. const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann2-')); try { const { roadmapPath: rp2 } = setupFixture(tmp2, {}); run(['roadmap', 'annotate-dependencies', '2.7'], tmp2); const unpadded = fs.readFileSync(rp2, 'utf-8'); assert.equal( after, unpadded, 'annotate-dependencies must produce identical output for padded and un-padded ids' ); // And the verb must not have silently destroyed the file (sanity). assert.match(after, /### Phase 2\.7:/, 'phase header must survive'); // Reference `before` to keep it from being dead-binding-flagged // and to assert the run did not corrupt the rest of the file. assert.ok(before.length > 0); } finally { cleanup(tmp2); } } finally { cleanup(tmp); } }); test('roadmap update-plan-progress: control case — already wired in 1.42.1', () => { // This is the one site already using phaseMarkdownRegexSource. Including // it as a control proves the parity assertion is a meaningful signal // (this test should pass on main, while the others fail). const { aRoadmap, bRoadmap } = expectParity({ fixtureOpts: {}, verbWithPadded: (cwd) => run(['roadmap', 'update-plan-progress', '02.7'], cwd), verbWithUnpadded: (cwd) => run(['roadmap', 'update-plan-progress', '2.7'], cwd), }); assert.equal( aRoadmap, bRoadmap, 'control verb must already produce identical output (wired in 1.42.1)' ); assert.match( aRoadmap, /- \[x\] \*\*Phase 2\.7:/, 'control verb must flip checkbox under both invocations' ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Regression: bug #2853 — roadmap.update-plan-progress deletes hand-written // annotations after the plan count. The count-bump regex's trailing `[^\n]+` // swallowed the whole line and the replacement wrote back only the regenerated // count, truncating any human prose after it. // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2853-plan-progress-annotation-preservation", () => { 'use strict'; const { test, describe } = 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 { execFileSync } = require('node:child_process'); const { cleanup } = require('./helpers.cjs'); const gsdTools2853 = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); function run2853(args, cwd) { try { return { stdout: execFileSync('node', [gsdTools2853, ...args], { cwd, timeout: 15000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }), ok: true, }; } catch (e) { return { stdout: (e.stdout && e.stdout.toString()) || '', stderr: (e.stderr && e.stderr.toString()) || '', ok: false, code: e.status, }; } } /** * Build a phase-10 fixture with one plan + matching summary + verification * `passed`, so the phase is "complete" for update-plan-progress. The ROADMAP * `Plans` summary line is supplied verbatim so each test pins a specific shape. */ function setupFixture2853(tmpDir, plansSummaryLine, opts = {}) { const { incomplete = false, eol = '\n' } = opts; const planningDir = path.join(tmpDir, '.planning'); const phaseDir = path.join(planningDir, 'phases', 'CK-10-test-phase'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'CK' })); fs.writeFileSync( path.join(planningDir, 'STATE.md'), `---\ncurrent_phase: 10\nstatus: executing\n---\n# State\n` ); fs.writeFileSync( path.join(phaseDir, '10-01-PLAN.md'), `---\nphase: 10\nplan: 1\nwave: 1\n---\n# Plan 1\n` ); fs.writeFileSync( path.join(phaseDir, '10-01-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\nDone.\n' ); fs.writeFileSync( path.join(phaseDir, '10-VERIFICATION.md'), incomplete ? '---\nstatus: pending\n---\n# Verification\nPending.\n' : '---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n' ); const roadmap = [ '# Roadmap', '', '## v1.0 Milestone', '', '- [ ] **Phase 10: Test Phase**', '', '## Progress', '', '| Phase | Plans | Status | Completed |', '|-------|-------|--------|-----------|', '| 10 Test Phase | 0/1 | Planned | - |', '', '### Phase 10: Test Phase', '', '**Goal:** reproduce', plansSummaryLine, '', 'Plans:', '- [ ] 10-01-PLAN.md', '', ].join('\n') + '\n'; fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap.replace(/\n/g, eol)); return { planningDir, roadmapPath: path.join(planningDir, 'ROADMAP.md') }; } describe('bug #2853: update-plan-progress preserves hand-written annotations', () => { test('row 1 — bold colon-outside form: annotation after count survives a complete bump', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r1-')); try { const annotation = '(11-16 are gap closure from VERIFICATION)'; const { roadmapPath } = setupFixture2853( tmp, `**Plans**: 0/1 plans executed ${annotation}` ); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const result = fs.readFileSync(roadmapPath, 'utf-8'); const line = result.split(/\r?\n/).find((l) => l.includes('**Plans**')); assert.ok(line, 'Plans summary line must exist'); assert.ok( line.includes(annotation), `annotation must survive; got: ${line}` ); assert.match(line, /1\/1 plans complete/, 'count must still bump to complete'); } finally { cleanup(tmp); } }); test('row 2 — **Plans:** colon-inside-bold form: annotation survives', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r2-')); try { const annotation = '(gap closure wave)'; const { roadmapPath } = setupFixture2853( tmp, `**Plans:** 0/1 plans executed ${annotation}` ); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const line = fs.readFileSync(roadmapPath, 'utf-8').split(/\r?\n/).find((l) => l.includes('**Plans:**')); assert.ok(line && line.includes(annotation), `annotation must survive; got: ${line}`); } finally { cleanup(tmp); } }); test('row 3 — plain Plans: header form: annotation survives', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r3-')); try { const annotation = '(see verification notes)'; const { roadmapPath } = setupFixture2853(tmp, `Plans: 0/1 plans executed ${annotation}`); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const result = fs.readFileSync(roadmapPath, 'utf-8'); const line = result.split(/\r?\n/).find((l) => /^Plans:/.test(l)); assert.ok(line && line.includes(annotation), `annotation must survive; got: ${line}`); } finally { cleanup(tmp); } }); test('row 4 — no trailing text: count updates with no whitespace regression', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r4-')); try { const { roadmapPath } = setupFixture2853(tmp, '**Plans:** 0/1 plans executed'); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const line = fs.readFileSync(roadmapPath, 'utf-8').split(/\r?\n/).find((l) => l.includes('**Plans:**')); assert.ok(line, 'Plans line must exist'); // Exact line: count bumped, no stray trailing space or duplicated text. assert.equal(line, '**Plans:** 1/1 plans complete'); } finally { cleanup(tmp); } }); test('row 5 — bare template form `N plans` (no slash) still gets count inserted', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r5-')); try { const { roadmapPath } = setupFixture2853(tmp, '**Plans:** 0 plans'); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const line = fs.readFileSync(roadmapPath, 'utf-8').split(/\r?\n/).find((l) => l.includes('**Plans:**')); assert.ok(line, 'Plans line must exist'); assert.match(line, /1\/1 plans complete/, 'bare form must still receive the canonical count'); } finally { cleanup(tmp); } }); test('row 6 — executed (non-complete) path: annotation survives', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r6-')); try { const annotation = '(half-done, gap closure pending)'; const { roadmapPath } = setupFixture2853( tmp, `**Plans:** 0/1 plans executed ${annotation}`, { incomplete: true } ); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const line = fs.readFileSync(roadmapPath, 'utf-8').split(/\r?\n/).find((l) => l.includes('**Plans:**')); assert.ok(line, 'Plans line must exist'); assert.ok(line.includes(annotation), `annotation must survive on executed path; got: ${line}`); assert.match(line, /1\/1 plans executed/, 'count must reflect executed (not complete) path'); } finally { cleanup(tmp); } }); test('row 7 — CRLF input: annotation text preserved (line-ending normalization is pre-existing)', () => { // The verb has always normalized ROADMAP.md line endings on its read-modify-write // (a pre-existing trait, not #2853's concern). What #2853 guarantees is that the // annotation TEXT survives the count bump on whatever line ending the verb emits. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r7-')); try { const annotation = '(CRLF note)'; const { roadmapPath } = setupFixture2853( tmp, `**Plans:** 0/1 plans executed ${annotation}`, { eol: '\r\n' } ); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const result = fs.readFileSync(roadmapPath, 'utf-8'); const plansLine = result.split(/\r?\n/).find((l) => l.includes('**Plans:**')); assert.ok(plansLine, 'Plans summary line must exist'); assert.ok( plansLine.includes(annotation), `annotation text must survive on a CRLF-origin file; got: ${plansLine}` ); assert.match(plansLine, /1\/1 plans complete/, 'count must still bump'); } finally { cleanup(tmp); } }); test('row 8 — idempotent re-run does not drop annotation', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r8-')); try { const annotation = '(idempotent check)'; const { roadmapPath } = setupFixture2853( tmp, `**Plans:** 0/1 plans executed ${annotation}` ); run2853(['roadmap', 'update-plan-progress', '10'], tmp); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const line = fs.readFileSync(roadmapPath, 'utf-8').split(/\r?\n/).find((l) => l.includes('**Plans:**')); assert.ok(line && line.includes(annotation), `annotation must survive a no-op re-run; got: ${line}`); } finally { cleanup(tmp); } }); test('row 9 — fresh-template placeholder is replaced, not glued to the new count', () => { // The canonical template ships `**Plans**: [Number of plans, e.g., "3 plans" or "TBD"]`. // A count bump must NOT preserve that bracketed guidance verbatim glued after the // count (pre-#2853 produced a clean `N/N plans complete`). The count replaces the // placeholder because there is no real count token for $2 to anchor a trailing-text // preservation to. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2853-r9-')); try { const placeholder = '[Number of plans, e.g., "3 plans" or "TBD"]'; const { roadmapPath } = setupFixture2853(tmp, `**Plans**: ${placeholder}`); run2853(['roadmap', 'update-plan-progress', '10'], tmp); const line = fs.readFileSync(roadmapPath, 'utf-8').split(/\r?\n/).find((l) => l.includes('**Plans**')); assert.ok(line, 'Plans line must exist'); assert.equal( line, '**Plans**: 1/1 plans complete', `placeholder must be replaced cleanly, not glued; got: ${line}` ); } finally { cleanup(tmp); } }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2268-parallel-discuss.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2268-parallel-discuss (consolidation epic #1969 B2 #1971)", () => { /** * Regression test for bug #2268 * * cmdInitProgress used a sliding-window pattern that set is_next_to_discuss * only on the FIRST undiscussed phase. Multiple independent undiscussed phases * could not be discussed in parallel — the manager only ever recommended one * discuss action at a time. * * Fix: mark ALL undiscussed phases as is_next_to_discuss = true so the user * can pick any of them. */ 'use strict'; const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); function writeRoadmap(tmpDir, phases) { const sections = phases.map(p => { let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** Do the thing\n`; return section; }).join('\n'); const checklist = phases.map(p => { const mark = p.complete ? 'x' : ' '; return `- [${mark}] **Phase ${p.number}: ${p.name}**`; }).join('\n'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}` ); } function writeState(tmpDir) { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n'); } let tmpDir; describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_to_discuss', () => { beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('two undiscussed phases: both marked is_next_to_discuss', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ { number: '1', name: 'Foundation' }, { number: '2', name: 'Cloud Deployment' }, ]); const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].is_next_to_discuss, true, 'phase 1 should be discussable'); assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'phase 2 should also be discussable'); }); test('two undiscussed phases: both get discuss recommendations', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ { number: '1', name: 'Foundation' }, { number: '2', name: 'Cloud Deployment' }, ]); const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); const discussActions = output.recommended_actions.filter(a => a.action === 'discuss'); assert.strictEqual(discussActions.length, 2, 'should recommend discuss for both undiscussed phases'); const phases = discussActions.map(a => a.phase).sort(); assert.deepStrictEqual(phases, ['1', '2']); }); test('five undiscussed phases: all five marked is_next_to_discuss', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ { number: '1', name: 'Alpha' }, { number: '2', name: 'Beta' }, { number: '3', name: 'Gamma' }, { number: '4', name: 'Delta' }, { number: '5', name: 'Epsilon' }, ]); const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); for (const phase of output.phases) { assert.strictEqual(phase.is_next_to_discuss, true, `phase ${phase.number} should be discussable`); } }); test('discussed phase stays false; undiscussed sibling is true', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ { number: '1', name: 'Foundation' }, { number: '2', name: 'API Layer' }, ]); // scaffold CONTEXT.md to mark phase 1 as discussed const dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# Context'); const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); assert.strictEqual(output.phases[0].is_next_to_discuss, false, 'discussed phase must not be is_next_to_discuss'); assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'undiscussed sibling must be is_next_to_discuss'); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/4-phase-complete-cjs-regression.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:4-phase-complete-cjs-regression (consolidation epic #1969 B3 #1972)", () => { 'use strict'; /** * Regression test for issue #4 (open-gsd/gsd-core): * bin/lib/phase.cjs cmdPhaseComplete — non-idempotent and unclamped. * * Root cause (pre-fix): * cmdPhaseComplete blindly increments "Completed Phases" by 1 on every call * (parseInt(completedRaw, 10) + 1) and recomputes Progress without a clamp. * Double-calling yields Completed Phases +2 (not +1), and Progress can exceed 100%. * * Fix: derive completed_phases from ROADMAP Complete-row count (idempotent) and * clamp percent to 100. This mirrors the SDK fix in phase-lifecycle.ts * (commit deriving from ROADMAP, referenced as "PR #3520" in issue #4). * * Note on test structure: phase-command-router.cjs delegates to the SDK when * the SDK dist is present, so the CJS path is exercised by calling * cmdPhaseComplete directly (bypassing the router), which is the actual * function containing the bug. * * References: * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) — architectural foundation * - /tmp/adr-3524-review-findings.md — architectural justification * - Issue #4 (open-gsd/gsd-core) */ 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, runGsdTools } = require('./helpers.cjs'); // ── Load cmdPhaseComplete directly from phase.cjs (bypass the SDK router) ──── // phase-command-router.cjs delegates to SDK when available; we must test the // CJS implementation directly since that is where the bug lives. const phaseModule = require('../gsd-core/bin/lib/phase.cjs'); const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); const { cmdPhaseComplete } = phaseModule; function writePassedVerificationFile(phaseDir, phase = '01') { fs.writeFileSync(path.join(phaseDir, `${phase}-VERIFICATION.md`), [ '---', 'status: passed', '---', '', '# Verification', '', ].join('\n')); } // ── Fixture builder ────────────────────────────────────────────────────────── /** * Creates a minimal fixture project with: * - ROADMAP.md with a 4-column progress table (Phase | Plans | Status | Completed) * - REQUIREMENTS.md with a phase-scoped REQ-ID and Traceability row * - STATE.md with Completed Phases: 0 and Total Phases: 2 (Progress 0%) * - Phase 01 directory with one plan+summary (to satisfy phase complete guard) * - Phase 02 directory (next phase) */ function createFixture(prefix = 'gsd-4-regression-', phase01DirName = '01-foundation') { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const planningDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planningDir, 'phases'); fs.mkdirSync(phasesDir, { recursive: true }); // ROADMAP.md: Phase 01 not yet complete, Phase 02 not started // 4-column progress table: Phase | Plans Complete | Status | Completed const roadmap = [ '# Roadmap', '', '- [ ] Phase 01: Foundation', '- [ ] Phase 02: API', '', '### Phase 01: Foundation', '**Goal:** Build the foundation', '**Requirements:** REQ-1', '**Plans:** 1 plans', '', '### Phase 02: API', '**Goal:** Build the API', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/1 | Not started | - |', '| 02. API | 0/1 | Not started | - |', '', ].join('\n'); fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); const requirements = [ '# Requirements', '', '## Functional Requirements', '', '- [ ] **REQ-1** Foundation must be complete.', '', '## Traceability', '', '| Requirement | Phase | Status |', '|-------------|-------|--------|', '| REQ-1 | Phase 01 | Pending |', '', ].join('\n'); fs.writeFileSync(path.join(planningDir, 'REQUIREMENTS.md'), requirements); // STATE.md: Completed Phases: 0, Total Phases: 2, Progress: 0% // Uses body-field format (bold **Field:** value) so the CJS handler's // stateExtractField/stateReplaceField path is exercised. const state = [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Current Plan:** 01-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working on phase 1', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n'); fs.writeFileSync(path.join(planningDir, 'STATE.md'), state); // Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes const phase01Dir = path.join(phasesDir, phase01DirName); fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); writePassedVerificationFile(phase01Dir); // Phase 02 directory (needed for "next phase" detection) fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); return tmpDir; } function readStateMd(tmpDir) { return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'); } function roadmapCompletionSnapshot(roadmapContent) { const snapshot = { phaseCheckboxes: [], progressRows: [], }; for (const line of roadmapContent.split(/\r?\n/)) { let match = line.match(/^- \[([ x])\] Phase ([^:]+): (.*)$/); if (match) { snapshot.phaseCheckboxes.push({ checked: match[1] === 'x', phase: match[2].trim(), title: match[3].replace(/\s+\(completed [^)]+\)$/, '').trim(), }); continue; } match = line.match(/^\|\s*(\d+[A-Z]?(?:\.\d+)*)\.?\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|$/i); if (match) { snapshot.progressRows.push({ phase: match[1].trim(), title: match[2].trim(), plans: match[3].trim(), status: match[4].trim(), completed: match[5].trim(), }); } } return snapshot; } function extractField(stateContent, fieldName) { const escaped = escapeRegex(fieldName); const boldMatch = stateContent.match(new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i')); if (boldMatch) return boldMatch[1].trim(); const plainMatch = stateContent.match(new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im')); return plainMatch ? plainMatch[1].trim() : null; } function extractFrontmatterField(stateContent, fieldName) { // Extract from YAML frontmatter block const fmMatch = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!fmMatch) return null; const fm = fmMatch[1]; // Handle both scalar and nested (progress.completed_phases) const parts = fieldName.split('.'); if (parts.length === 1) { const m = fm.match(new RegExp(`^${parts[0]}:\\s*(.+)`, 'm')); return m ? m[1].trim() : null; } // Nested: e.g. "progress.completed_phases" const sectionMatch = fm.match(new RegExp(`^${parts[0]}:\\s*\\n([\\s\\S]*?)(?=\\n[a-z]|$)`, 'm')); if (!sectionMatch) return null; const sectionContent = sectionMatch[1]; const fieldMatch = sectionContent.match(new RegExp(`^\\s+${parts[1]}:\\s*(.+)`, 'm')); return fieldMatch ? fieldMatch[1].trim() : null; } // Capture stdout from `gsd-tools phase complete ` via a REAL subprocess. // // #3057: this used to call cmdPhaseComplete(...) IN-PROCESS and capture its // stdout by monkeypatching fs.writeSync (io.cts's output() writes via // writeAllSync -> fs.writeSync(1, ...), not process.stdout.write — bug #1008's // non-blocking-pipe fix). That interception shares the exact seam the remote // matrix's own event-stream capture depends on: when the runner's stdout is // redirected to a file (the common case for a captured CI child), // `process.stdout.write` itself resolves through that SAME public // `fs.writeSync`, so any write racing the patched window — including the // runner's own test:pass/test:fail events — could be silently swallowed into // `chunks` or dropped instead of reaching the real fd. Two attempts to make // that interception safe (manual save/restore, then `t.mock.method`) both // still left this file reporting zero test:pass/test:fail events on the // remote matrix. The fix is to stop intercepting fd 1 altogether: run the // real CLI in a real subprocess, exactly like every other test in this file // already does via `runGsdTools`, so the OS owns stdout capture and the // runner's own event stream is never at risk. // // The phase family router (phase-command-router.cjs) calls // `phase.cmdPhaseComplete(cwd, phaseNum, raw)` directly for `phase complete` // — no SDK delegation on this path — so this reaches the exact same CJS // function the old in-process call did. // // A handful of call sites depend on state a subprocess cannot see (a mock // installed in THIS process, or the parent's own fs.writeFileSync mock for // the rollback-failure tests below); those call sites do not use this // helper — see the inline notes at each one. function capturePhaseComplete(t, cwd, phaseNum) { const result = runGsdTools(['phase', 'complete', String(phaseNum)], cwd); if (!result.success) { // Surface exitCode/error verbatim so a real failure never presents as a // downstream `JSON.parse('')` error, and so assert.throws() callers keep // matching against the real stderr text (e.g. "verification is // incomplete..."). throw new Error( result.error || `cmdPhaseComplete failed (exitCode=${result.exitCode})`, ); } return result.output; } // ── T1: Double invocation must NOT double-increment Completed Phases ───────── describe('issue #4 (CJS): cmdPhaseComplete — idempotency (blind-increment bug)', () => { let tmpDir; beforeEach(() => { tmpDir = createFixture(); }); afterEach(() => { cleanup(tmpDir); }); test('T1: double invocation does NOT double-increment Completed Phases in STATE.md body', (t) => { // First call — legitimate completion capturePhaseComplete(t, tmpDir, '1'); const stateAfter1 = readStateMd(tmpDir); const completedAfter1Body = extractField(stateAfter1, 'Completed Phases'); const completedAfter1Fm = extractFrontmatterField(stateAfter1, 'progress.completed_phases'); // After first call: Completed Phases in the body should be 1 // (derived from ROADMAP: 1 Complete row after marking phase 01 complete) // OR the YAML frontmatter completed_phases should be 1 const completedAfter1 = completedAfter1Body || completedAfter1Fm; assert.equal( completedAfter1, '1', `After first call: completed_phases should be 1.\n` + `Body field: ${completedAfter1Body}, FM field: ${completedAfter1Fm}\n\n` + `STATE:\n${stateAfter1}`, ); // Second call on the same phase — must be idempotent capturePhaseComplete(t, tmpDir, '1'); const stateAfter2 = readStateMd(tmpDir); const completedAfter2Body = extractField(stateAfter2, 'Completed Phases'); const completedAfter2Fm = extractFrontmatterField(stateAfter2, 'progress.completed_phases'); const completedAfter2 = completedAfter2Body || completedAfter2Fm; // Pre-fix: completedAfter2 would be "2" (blind increment: 1+1=2) // Post-fix: must remain "1" (derived from ROADMAP) assert.equal( completedAfter2, '1', `T1 FAILED: Completed Phases was double-incremented.\n` + `After first call: ${completedAfter1}, after second call: ${completedAfter2}.\n` + `This is the #4 non-idempotency bug — blind parseInt+1 instead of deriving from ROADMAP.\n\n` + `STATE after second call (body: ${completedAfter2Body}, fm: ${completedAfter2Fm}):\n${stateAfter2}`, ); }); test('rolls back ROADMAP when STATE write fails during phase completion', (t) => { const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8'); const originalReq = fs.readFileSync(reqPath, 'utf8'); const originalState = fs.readFileSync(statePath, 'utf8'); const originalWriteFileSync = fs.writeFileSync; t.mock.method(fs, 'writeFileSync', function injectedStateWriteFailure(target, ...args) { const targetPath = String(target); const isStatePublish = targetPath === statePath || targetPath === `${statePath}.tmp.${process.pid}`; if (isStatePublish) { const err = new Error('injected STATE.md write failure'); err.code = 'EIO'; throw err; } return originalWriteFileSync.call(this, target, ...args); }); // Calls cmdPhaseComplete directly (bypassing capturePhaseComplete's // subprocess helper): this test's fault is a `t.mock.method(fs, // 'writeFileSync', ...)` installed in THIS process, which a subprocess // cannot see. No stdout capture is needed here — only the thrown // exception and the resulting on-disk file state — so calling the CJS // function directly does not touch fd 1 and does not reinstate the // fs.writeSync interception this file removed. assert.throws( () => cmdPhaseComplete(tmpDir, '1', false), /injected STATE\.md write failure/, ); const roadmapAfter = fs.readFileSync(roadmapPath, 'utf8'); const reqAfter = fs.readFileSync(reqPath, 'utf8'); const stateAfter = fs.readFileSync(statePath, 'utf8'); assert.deepEqual( roadmapCompletionSnapshot(roadmapAfter), roadmapCompletionSnapshot(originalRoadmap), 'ROADMAP.md should roll back to its original completion state', ); assert.equal(reqAfter, originalReq, 'REQUIREMENTS.md should roll back when STATE.md write fails'); assert.equal(stateAfter, originalState, 'STATE.md should remain unchanged after injected write failure'); }); test('rolls back ROADMAP when REQUIREMENTS write fails during phase completion', (t) => { const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8'); const originalReq = fs.readFileSync(reqPath, 'utf8'); const originalWriteFileSync = fs.writeFileSync; t.mock.method(fs, 'writeFileSync', function injectedRequirementsWriteFailure(target, ...args) { const targetPath = String(target); if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) { const err = new Error('injected REQUIREMENTS.md write failure'); err.code = 'EIO'; throw err; } return originalWriteFileSync.call(this, target, ...args); }); // See the parent-process-mock note in the STATE.md-write-fails test // above: this must call cmdPhaseComplete directly, not via // capturePhaseComplete's subprocess helper. assert.throws( () => cmdPhaseComplete(tmpDir, '1', false), /injected REQUIREMENTS\.md write failure/, ); assert.deepEqual( roadmapCompletionSnapshot(fs.readFileSync(roadmapPath, 'utf8')), roadmapCompletionSnapshot(originalRoadmap), 'ROADMAP.md should roll back when the REQUIREMENTS write fails', ); assert.equal(fs.readFileSync(reqPath, 'utf8'), originalReq, 'REQUIREMENTS.md should be unchanged'); }); test('reports rollback failure when restoring an earlier planning file fails', (t) => { const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); const originalWriteFileSync = fs.writeFileSync; let requirementsWriteFailed = false; t.mock.method(fs, 'writeFileSync', function injectedRollbackFailure(target, ...args) { const targetPath = String(target); if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) { requirementsWriteFailed = true; const err = new Error('injected REQUIREMENTS.md write failure'); err.code = 'EIO'; throw err; } if (requirementsWriteFailed && (targetPath === roadmapPath || targetPath === `${roadmapPath}.tmp.${process.pid}`)) { const err = new Error('injected ROADMAP.md rollback failure'); err.code = 'EIO'; throw err; } return originalWriteFileSync.call(this, target, ...args); }); // See the parent-process-mock note in the STATE.md-write-fails test // above: this must call cmdPhaseComplete directly, not via // capturePhaseComplete's subprocess helper. assert.throws( () => cmdPhaseComplete(tmpDir, '1', false), /injected REQUIREMENTS\.md write failure[\s\S]*WARNING: rollback failed while restoring[\s\S]*injected ROADMAP\.md rollback failure/, ); }); }); // ───────────────────────────────────────────────────────────────────────────── // #3057 B3: cmdPhaseComplete surfaces an indeterminate staleness check // // readVerificationStatus's internal staleness check can itself fail (fs / // scanPhasePlans / clock error). Pre-#3057 B3, that failure was silently // identical to a completed check that genuinely found nothing stale — the // SAME fail-open shape as #3050. B3 flags this on the result // (`staleCheckIndeterminate`); this test proves phase.cts actually SURFACES // that flag (into `warnings[]`, the same advisory channel the UAT/VERIFICATION // pre-scan above already uses) rather than dropping it on the floor. // ───────────────────────────────────────────────────────────────────────────── describe('#3057 B3: cmdPhaseComplete — verification staleness-check indeterminate is surfaced', () => { let tmpDir; beforeEach(() => { tmpDir = createFixture('gsd-3057-b3-phase-'); }); afterEach(() => { cleanup(tmpDir); }); test( 'an fs failure inside the staleness check adds a warning; completion routing is unchanged', { skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, (t) => { const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); const summaryPath = path.join(phase01Dir, '01-01-SUMMARY.md'); // Real, on-disk fault instead of an in-process fs.statSync mock: this now // runs cmdPhaseComplete in a subprocess (via capturePhaseComplete), which // cannot see a mock installed in this process. findStaleVerificationSummary // (verification.cjs) calls fs.statSync on each summary file to compare // mtimes, and statSync follows symlinks — so pointing the summary at a // target that does not exist reproduces a genuine ENOENT there, degrading // the staleness check to {determined:false} exactly like the removed // injected statSync throw did. scanPhasePlans only matches summary // *filenames* (never stats them), so the plan-coverage gate still sees // the summary as present. fs.unlinkSync(summaryPath); fs.symlinkSync(path.join(phase01Dir, '.does-not-exist'), summaryPath); const output = JSON.parse(capturePhaseComplete(t, tmpDir, '1')); // Pre-existing no-throw fail-open routing is UNCHANGED: the phase still // completes exactly as it would have before #3057 B3. assert.strictEqual(output.completed_phase, '1'); assert.ok(Array.isArray(output.warnings), 'result must carry a warnings array'); assert.strictEqual( output.verification_stale_check_indeterminate, true, `result must surface the indeterminate staleness check as a typed field; got ${JSON.stringify(output.warnings)}`, ); assert.strictEqual(output.has_warnings, true); }, ); test('a completed staleness check that finds nothing stale does NOT add an indeterminate warning', (t) => { const output = JSON.parse(capturePhaseComplete(t, tmpDir, '1')); assert.strictEqual(output.completed_phase, '1'); assert.strictEqual( output.verification_stale_check_indeterminate, false, `must not report an indeterminate check when the staleness check ran to completion; got ${JSON.stringify(output.warnings)}`, ); }); test( 'a BLOCKED completion (status=human_needed) with an indeterminate staleness check still blocks, but the error note says so', { skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, () => { const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.writeFileSync(path.join(phase02Dir, '02-01-PLAN.md'), '# Plan\nDo the work.\n'); fs.writeFileSync(path.join(phase02Dir, '02-VERIFICATION.md'), [ '---', 'status: human_needed', '---', '', '# Verification', '', ].join('\n')); // Real, on-disk fault — see the note in the sibling test above. The // summary is a dangling symlink so fs.statSync (inside // findStaleVerificationSummary, running in the subprocess) throws ENOENT. const summaryPath = path.join(phase02Dir, '02-01-SUMMARY.md'); fs.symlinkSync(path.join(phase02Dir, '.does-not-exist'), summaryPath); // Routing is UNCHANGED — status !== 'passed' already blocked before #3057 // B3; the note is purely additive to the message text. Assert the fact // structurally (via --json-errors) rather than regexing the human- // readable note — CONTRIBUTING requires a typed surface alongside any // text a caller might otherwise only match on, and once that typed // surface exists the test must assert on IT, not also on the rendered // prose (src/phase.cts's human message wording is out of scope for this // test — operators read it, but the test must not lock its exact text). const result = runGsdTools(['--json-errors', 'phase', 'complete', '2'], tmpDir); assert.equal(result.success, false, 'phase complete must fail when verification is blocked'); const errorPayload = JSON.parse(result.error); assert.equal(errorPayload.reason, 'phase_verification_incomplete'); assert.equal(errorPayload.verification_stale_check_indeterminate, true); }, ); }); // ───────────────────────────────────────────────────────────────────────────── // #3511: cmdPhaseComplete — UAT/VERIFICATION advisory pre-scan is phase-scoped // // A cross-phase, stray, or ad-hoc file sitting in this phase's directory must // not name an advisory warning against this phase — and this phase's own // UAT/VERIFICATION files must keep warning exactly as before (non-stray case // unchanged). Covers BOTH loops scoped by #3511 (the UAT loop and the // VERIFICATION loop). // ───────────────────────────────────────────────────────────────────────────── describe('#3511: cmdPhaseComplete — advisory pre-scan warnings are phase-scoped', () => { let tmpDir; beforeEach(() => { tmpDir = createFixture(); }); afterEach(() => { cleanup(tmpDir); }); test('a cross-phase stray UAT/VERIFICATION file does not contribute a warning; this phase\'s own files still do', (t) => { const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); // This phase's own UAT file — must still produce its usual warning. fs.writeFileSync(path.join(phase01Dir, '01-UAT.md'), [ '---', 'status: partial', '---', '', '### 1. Test A', 'expected: A', 'result: pending', '', ].join('\n')); // Cross-phase strays sitting in phase 01's directory — token "02", not // "01" — must NOT contribute a warning to phase 01's completion. fs.writeFileSync(path.join(phase01Dir, '02-UAT.md'), [ '---', 'status: partial', '---', '', '### 1. Test B', 'expected: B', 'result: blocked', '', ].join('\n')); fs.writeFileSync(path.join(phase01Dir, '02-VERIFICATION.md'), [ '---', 'status: human_needed', '---', '', '# Verification', '', ].join('\n')); const output = JSON.parse(capturePhaseComplete(t, tmpDir, '1')); assert.strictEqual(output.completed_phase, '1'); assert.ok( output.warnings.some((w) => w.includes('01-UAT.md') && w.includes('has pending tests')), `own UAT file must still warn; got: ${JSON.stringify(output.warnings)}`, ); assert.ok( !output.warnings.some((w) => w.includes('02-UAT.md')), `stray UAT file must not contribute a warning; got: ${JSON.stringify(output.warnings)}`, ); assert.ok( !output.warnings.some((w) => w.includes('02-VERIFICATION.md') || /needs human verification/.test(w)), `stray VERIFICATION file must not contribute a warning; got: ${JSON.stringify(output.warnings)}`, ); }); test('#3511 follow-up: own UAT file still warns from a NON-canonical dir shape "1-unpadded" (over-exclusion check)', (t) => { const unpaddedTmpDir = createFixture('gsd-4-regression-unpadded-', '1-unpadded'); try { const phaseDir = path.join(unpaddedTmpDir, '.planning', 'phases', '1-unpadded'); // "1-unpadded" tokenizes to literal "1"; scaffold writes the PADDED // "01-…" form. A literal token compare excluded the phase's own file. fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: partial', '---', '', '### 1. Test A', 'expected: A', 'result: pending', '', ].join('\n')); const output = JSON.parse(capturePhaseComplete(t, unpaddedTmpDir, '1')); assert.ok( output.warnings.some((w) => w.includes('01-UAT.md') && w.includes('has pending tests')), `own UAT file in an unpadded-dir phase must still warn; got: ${JSON.stringify(output.warnings)}`, ); } finally { cleanup(unpaddedTmpDir); } }); }); // ───────────────────────────────────────────────────────────────────────────── // Regressions: phase complete preserves completion date (#1161) // Tests drive the REAL handler (cmdPhaseComplete) via the CLI entry point // `runGsdTools('phase complete ')` so the fix in phase.cts is exercised // end-to-end rather than hitting the roadmap.cjs helper in isolation. // ───────────────────────────────────────────────────────────────────────────── /** Extract the Completed cell from the progress table row for a given phase number. * The Completed column is always the LAST cell, regardless of whether the table is * 4-col (Phase | Plans | Status | Completed) or 5-col (Phase | Milestone | Plans | Status | Completed). */ function extractCompletedCell(roadmapContent, phaseNum) { // Match the full progress table row whose first cell starts with the phase number. // Use [^|\n] to avoid crossing line boundaries. Capture everything up to the final '|'. const re = new RegExp(`^(\\|\\s*${phaseNum}[^|\\n]*(?:\\|[^|\\n]*)*)\\|\\s*$`, 'm'); const m = roadmapContent.match(re); if (!m) return null; // m[1] = '| 01. Foundation | 1/1 | Complete | 2026-01-01 ' // Split on '|' → ['', ' 01. Foundation ', ' 1/1 ', ' Complete ', ' 2026-01-01 '] // Drop the leading empty string and take the last element. const cells = m[1].split('|').slice(1); // drop leading '' return cells[cells.length - 1].trim(); } /** * Build a minimal 4-col ROADMAP project fixture whose Phase 01 row already has * the Completed cell set to `existingDate` and Status `Complete`. * The phase directory has plan+summary so `phase complete 1` can run. * * @param {string} existingDate - value in the Completed cell ('2026-01-01', '-', ' ', etc.) * @param {boolean} [alreadyComplete] - if true the checkbox is already checked and status Complete */ function create4ColFixture(existingDate, alreadyComplete = true) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-4col-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); fs.mkdirSync(phasesDir, { recursive: true }); const checkbox = alreadyComplete ? '[x]' : '[ ]'; const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : ''; const status = alreadyComplete ? 'Complete ' : 'Not started'; const roadmap = [ '# Roadmap', '', `- ${checkbox} Phase 01: Foundation${checkboxSuffix}`, '- [ ] Phase 02: API', '', '### Phase 01: Foundation', '**Goal:** Build the foundation', '**Plans:** 1/1 plans complete', '', '### Phase 02: API', '**Goal:** Build the API', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', `| 01. Foundation | 1/1 | ${status} | ${existingDate} |`, '| 02. API | 0/1 | Not started | - |', '', ].join('\n'); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap); const state = [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Current Plan:** 01-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working on phase 1', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n'); fs.writeFileSync(path.join(planDir, 'STATE.md'), state); const phase01Dir = path.join(phasesDir, '01-foundation'); fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); writePassedVerificationFile(phase01Dir); fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); return tmpDir; } /** * Build a minimal 5-col ROADMAP project fixture (Phase | Milestone | Plans | Status | Completed). * Phase 01 row already has Completed cell set to `existingDate`. */ function create5ColFixture(existingDate, alreadyComplete = true) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-5col-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); fs.mkdirSync(phasesDir, { recursive: true }); const checkbox = alreadyComplete ? '[x]' : '[ ]'; const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : ''; const status = alreadyComplete ? 'Complete ' : 'Not started'; const roadmap = [ '# Roadmap', '', `- ${checkbox} Phase 01: Foundation${checkboxSuffix}`, '- [ ] Phase 02: API', '', '### Phase 01: Foundation', '**Goal:** Build the foundation', '**Plans:** 1/1 plans complete', '', '### Phase 02: API', '**Goal:** Build the API', '', '## Progress', '', '| Phase | Milestone | Plans | Status | Completed |', '|-------|-----------|-------|--------|-----------|', `| 01. Foundation | v1.0 | 1/1 | ${status} | ${existingDate} |`, '| 02. API | v1.0 | 0/1 | Not started | - |', '', ].join('\n'); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap); const state = [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Current Plan:** 01-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working on phase 1', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n'); fs.writeFileSync(path.join(planDir, 'STATE.md'), state); const phase01Dir = path.join(phasesDir, '01-foundation'); fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); writePassedVerificationFile(phase01Dir); fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); return tmpDir; } // Fixed historical instant — will never collide with a real today() in CI. const PINNED_MS_1161 = Date.parse('2021-03-22T10:00:00.000Z'); const PINNED_DATE_1161 = '2021-03-22'; // Env passed to runGsdTools to pin the clock in the subprocess SUT. const PINNED_CLOCK_ENV = { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS_1161), }; describe('regressions: phase complete preserves completion date (#1161)', () => { let tmpDir; afterEach(() => { cleanup(tmpDir); }); // ── (a) 4-col: already Complete with a date — repeat phase complete must NOT overwrite ── test('#1161 (a): 4-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => { // Arrange: Row is already Complete with '2026-01-01'. tmpDir = create4ColFixture('2026-01-01', true); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); // Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'. const after = fs.readFileSync(roadmapPath, 'utf8'); const completedCell = extractCompletedCell(after, '01'); assert.strictEqual( completedCell, '2026-01-01', `#1161 (a) FAILED: repeat phase complete on 4-col table overwrote the existing date.\n` + `Expected '2026-01-01', got '${completedCell}'.\n` + `Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` + `ROADMAP after:\n${after}`, ); }); // ── (b) 5-col: already Complete with a date — repeat phase complete must NOT overwrite ── test('#1161 (b): 5-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => { // Arrange: 5-col table row is already Complete with '2026-01-01'. tmpDir = create5ColFixture('2026-01-01', true); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); // Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'. const after = fs.readFileSync(roadmapPath, 'utf8'); const completedCell5 = extractCompletedCell(after, '01'); assert.strictEqual( completedCell5, '2026-01-01', `#1161 (b) FAILED: repeat phase complete on 5-col table overwrote the existing date.\n` + `Expected '2026-01-01', got '${completedCell5}'.\n` + `Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` + `ROADMAP after:\n${after}`, ); }); // ── (c) First-time completion (placeholder '-') must stamp the pinned date ── test('#1161 (c): 4-col ROADMAP — first `phase complete 1` (placeholder date) stamps pinned date', () => { // Arrange: Row has '-' as Completed cell and is Not started (never completed). tmpDir = create4ColFixture('-', false); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); // Act: first-time phase complete. const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); // Assert: Completed cell is now the pinned date. const after = fs.readFileSync(roadmapPath, 'utf8'); const completedCell = extractCompletedCell(after, '01'); assert.strictEqual( completedCell, PINNED_DATE_1161, `#1161 (c) FAILED: first-time completion should stamp '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` + `ROADMAP after:\n${after}`, ); }); // ── (d) Whitespace-only Completed cell is treated as empty and gets stamped ── test('#1161 (d): 4-col ROADMAP — whitespace-only Completed cell treated as empty, gets stamped', () => { // Arrange: Row has ' ' (spaces) as Completed cell. tmpDir = create4ColFixture(' ', false); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); // Act: first-time phase complete. const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); // Assert: Completed cell is now the pinned date (whitespace was treated as empty). const after = fs.readFileSync(roadmapPath, 'utf8'); const completedCell = extractCompletedCell(after, '01'); assert.strictEqual( completedCell, PINNED_DATE_1161, `#1161 (d) FAILED: whitespace-only Completed cell should be stamped '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` + `ROADMAP after:\n${after}`, ); }); // ── (e) Non-date garbage in Completed cell is self-healed and gets re-stamped ── test('#1161 (e): 5-col ROADMAP — non-date garbage Completed cell is self-healed and re-stamped', () => { // Arrange: 5-col row is already Complete but the Completed cell contains 'TBD' // (a non-date garbage value that the old guard would have preserved). tmpDir = create5ColFixture('TBD', true); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); // Assert: garbage 'TBD' must be replaced with the pinned date (self-heal). // Pre-Fix 2: the old guard (existingDate && existingDate !== '-') would preserve 'TBD'. // Post-Fix 2: the date-shape guard (/^\d{4}-\d{2}-\d{2}$/) rejects 'TBD' → re-stamps. const after = fs.readFileSync(roadmapPath, 'utf8'); const completedCell = extractCompletedCell(after, '01'); assert.strictEqual( completedCell, PINNED_DATE_1161, `#1161 (e) FAILED: non-date garbage 'TBD' in Completed cell should be self-healed to '${PINNED_DATE_1161}', got '${completedCell}'.\n` + `Old guard (non-empty && !== '-') would preserve 'TBD'. New guard must require a date shape.\n\n` + `ROADMAP after:\n${after}`, ); }); }); // ── T2: Progress percent must never exceed 100% ────────────────────────────── describe('issue #4 (CJS): cmdPhaseComplete — progress percent clamp', () => { let tmpDir; afterEach(() => { cleanup(tmpDir); }); test('T2: Progress percent never exceeds 100 after double invocation', (t) => { tmpDir = createFixture(); // Pre-load STATE.md with Completed Phases: 1, Total Phases: 1 (already 100%) // so that a blind +1 on a second call would yield 200% let stateContent = readStateMd(tmpDir); stateContent = stateContent.replace('**Completed Phases:** 0', '**Completed Phases:** 1'); stateContent = stateContent.replace('**Total Phases:** 2', '**Total Phases:** 1'); stateContent = stateContent.replace('**Progress:** 0%', '**Progress:** 100%'); fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent); // Also update ROADMAP to show just 1 phase total const roadmap = [ '# Roadmap', '', '- [ ] Phase 01: Foundation', '', '### Phase 01: Foundation', '**Goal:** Build the foundation', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/1 | Not started | - |', '', ].join('\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); // First call capturePhaseComplete(t, tmpDir, '1'); // Second call — this is the problematic one capturePhaseComplete(t, tmpDir, '1'); const stateAfterBoth = readStateMd(tmpDir); // Check body Progress field const progressStr = extractField(stateAfterBoth, 'Progress'); const fmPercent = extractFrontmatterField(stateAfterBoth, 'progress.percent'); // Try to extract numeric percent from either source const bodyPercentMatch = progressStr && progressStr.match(/(\d+)%/); const bodyPercent = bodyPercentMatch ? parseInt(bodyPercentMatch[1], 10) : null; const fmPercentNum = fmPercent ? parseInt(fmPercent, 10) : null; // At least one of body or frontmatter percent must exist and be ≤ 100 const anyPercent = bodyPercent ?? fmPercentNum; assert.ok( anyPercent !== null, `T2: Could not find any percent value in STATE.md\n\nSTATE:\n${stateAfterBoth}`, ); assert.ok( anyPercent <= 100, `T2 FAILED: Progress percent exceeds 100.\n` + `Body Progress: "${progressStr}" (${bodyPercent}%), FM percent: ${fmPercentNum}%\n` + `This is the #4 unclamped-percent bug — (N+1)/total can exceed 100.\n\n` + `STATE:\n${stateAfterBoth}`, ); }); }); // ───────────────────────────────────────────────────────────────────────────── // Regressions: issue #1159 — Defect A // VERIFICATION.md with `previous_status: gaps_found` in the body but // `status: passed` in frontmatter must NOT emit a "has unresolved gaps" warning. // The bug: /status: gaps_found/.test(fullContent) matches the substring inside // `previous_status: gaps_found`, causing a false positive. // ───────────────────────────────────────────────────────────────────────────── /** * Build a minimal project fixture with a VERIFICATION.md file whose * frontmatter status is `verFmStatus` and whose body contains `previous_status: gaps_found`. * Phase 01 has a plan+summary; Phase 02 exists for next-phase detection. */ function createVerificationFixture(verFmStatus) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-verif-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); const phase01Dir = path.join(phasesDir, '01-foundation'); fs.mkdirSync(phase01Dir, { recursive: true }); fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 01: Foundation', '- [ ] Phase 02: API', '', '### Phase 01: Foundation', '**Goal:** Build the foundation', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/1 | Not started | - |', '| 02. API | 0/1 | Not started | - |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n')); // No REQUIREMENTS.md intentionally (not needed for this defect check) fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); // The VERIFICATION.md has the CURRENT status in frontmatter but historical // `previous_status: gaps_found` in the body — this is the false-positive trigger. fs.writeFileSync(path.join(phase01Dir, '01-VERIFICATION.md'), [ '---', `status: ${verFmStatus}`, 'phase: "01"', '---', '', '# Verification', '', '', 'previous_status: gaps_found', '', '## Summary', 'All checks passed on re-run.', '', ].join('\n')); return tmpDir; } describe('issue #1159 (Defect A): VERIFICATION.md historical metadata must not trigger gap warning', () => { let tmpDir; afterEach(() => { cleanup(tmpDir); }); test( '#1159-A-1: status:passed + previous_status:gaps_found in body → NO "has unresolved gaps" warning', () => { tmpDir = createVerificationFixture('passed'); const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); // The output is JSON; parse and check warnings array const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const gapWarnings = warnings.filter((w) => /unresolved gaps/i.test(w)); assert.equal( gapWarnings.length, 0, `#1159-A-1 FAILED: got false gap warning(s) when frontmatter status=passed.\n` + `Warnings: ${JSON.stringify(warnings)}\n` + `(The regex /status: gaps_found/ matched 'previous_status: gaps_found' in the body.)`, ); }, ); test( '#1159-A-2 (boundary): status:gaps_found in frontmatter → blocks phase completion', () => { tmpDir = createVerificationFixture('gaps_found'); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'gaps_found verification must block phase completion'); const parsed = JSON.parse(result.error); assert.equal(parsed.reason, 'phase_verification_incomplete'); assert.match(parsed.message, /Gaps found/i); }, ); test( '#1159-A-3 (boundary): status:human_needed in frontmatter → blocks phase completion', () => { tmpDir = createVerificationFixture('human_needed'); const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); assert.equal(result.success, false, 'human_needed verification must block phase completion'); const parsed = JSON.parse(result.error); assert.equal(parsed.reason, 'phase_verification_incomplete'); assert.match(parsed.message, /Human verification required/i); }, ); }); // ───────────────────────────────────────────────────────────────────────────── // Regressions: issue #1159 — Defect B // Requirement IDs (e.g. FILE-001) that appear under explicitly deferred/future/v2 // sections in REQUIREMENTS.md must NOT be flagged as "missing from Traceability". // ───────────────────────────────────────────────────────────────────────────── /** * Build a minimal project fixture with a REQUIREMENTS.md that has: * - An active requirement ACTIVE-001 that IS in the Traceability table * - A deferred requirement DEFER-001 under a "Deferred v2 Requirements" heading * that is NOT in the Traceability table (correctly out of scope) * - Optionally a truly-missing active requirement MISSING-001 (not in table) */ function createDeferredReqFixture({ includeMissingActive = false } = {}) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-deferred-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); const phase01Dir = path.join(phasesDir, '01-foundation'); fs.mkdirSync(phase01Dir, { recursive: true }); fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); const missingActiveLines = includeMissingActive ? ['', '- **MISSING-001** Active req not in traceability table.'] : []; fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ '# Requirements', '', '## Functional Requirements', '', '- **ACTIVE-001** Core feature must work.', ...missingActiveLines, '', '## Deferred v2 Requirements', '', '- **DEFER-001** Nice-to-have for v2, explicitly out of scope.', '', '## Future Backlog', '', '- **FUTURE-001** Consider for next major release.', '', '## Traceability', '', '| Requirement | Phase | Status |', '|-------------|-------|--------|', '| ACTIVE-001 | Phase 01 | Pending |', '', ].join('\n')); // Roadmap references ACTIVE-001 for phase 01 fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 01: Foundation', '- [ ] Phase 02: API', '', '### Phase 01: Foundation', '**Goal:** Build the foundation', '**Requirements:** ACTIVE-001', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/1 | Not started | - |', '| 02. API | 0/1 | Not started | - |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n')); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); writePassedVerificationFile(phase01Dir); return tmpDir; } describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigger traceability warning', () => { let tmpDir; afterEach(() => { cleanup(tmpDir); }); test( '#1159-B-1: IDs under "Deferred v2 Requirements" and "Future Backlog" sections → NO traceability warning', () => { tmpDir = createDeferredReqFixture({ includeMissingActive: false }); const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); assert.equal( traceWarnings.length, 0, `#1159-B-1 FAILED: got false traceability warning(s) for deferred/future IDs.\n` + `Warnings: ${JSON.stringify(warnings)}\n` + `(DEFER-001 and FUTURE-001 are under deferred/future sections and must be ignored.)`, ); }, ); test( '#1159-B-2 (boundary): truly-missing ACTIVE ID (not in table, not in deferred section) → DOES warn', () => { tmpDir = createDeferredReqFixture({ includeMissingActive: true }); const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); assert.ok( traceWarnings.length > 0, `#1159-B-2 FAILED: expected traceability warning for MISSING-001 (active, not in table) but got none.\n` + `Warnings: ${JSON.stringify(warnings)}`, ); // Verify MISSING-001 is specifically mentioned const mentionsMissing = traceWarnings.some((w) => w.includes('MISSING-001')); assert.ok( mentionsMissing, `#1159-B-2 FAILED: warning exists but MISSING-001 not mentioned.\n` + `Traceability warnings: ${JSON.stringify(traceWarnings)}`, ); }, ); test( '#1159-B-3 (boundary): deferred IDs must not contaminate warning even when active ID is also missing', () => { tmpDir = createDeferredReqFixture({ includeMissingActive: true }); const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); // DEFER-001 and FUTURE-001 must NOT appear in the traceability warnings const mentionsDefer = traceWarnings.some((w) => w.includes('DEFER-001') || w.includes('FUTURE-001')); assert.ok( !mentionsDefer, `#1159-B-3 FAILED: deferred IDs (DEFER-001/FUTURE-001) appeared in traceability warning.\n` + `Traceability warnings: ${JSON.stringify(traceWarnings)}`, ); }, ); test( '#1159-B-4 (subheading): IDs under sub-headings of a deferred section are also suppressed', () => { // Codex adversarial finding: splitting on EVERY heading failed to propagate // deferred status to sub-headings (e.g. "## Future Backlog" → "### Sub"). // The fix uses heading-depth tracking so sub-headings inherit deferred state. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-subhead-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); const phase01Dir = path.join(phasesDir, '01-foundation'); fs.mkdirSync(phase01Dir, { recursive: true }); fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ '# Requirements', '', '## Functional Requirements', '', '- **ACTIVE-001** Core feature.', '', '## Future Backlog', '', '### Sub-category A', '', '- **SUB-001** This is under a sub-heading of a deferred section.', '', '## Traceability', '', '| Requirement | Phase | Status |', '|-------------|-------|--------|', '| ACTIVE-001 | Phase 01 | Pending |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 01: Foundation', '- [ ] Phase 02: API', '', '### Phase 01: Foundation', '**Goal:** Build the foundation', '**Requirements:** ACTIVE-001', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Foundation | 0/1 | Not started | - |', '| 02. API | 0/1 | Not started | - |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n')); fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\n'); fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\n'); writePassedVerificationFile(phase01Dir); const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); const mentionsSub = traceWarnings.some((w) => w.includes('SUB-001')); assert.ok( !mentionsSub, `#1159-B-4 FAILED: SUB-001 (under sub-heading of deferred section) appeared in warning.\n` + `Traceability warnings: ${JSON.stringify(traceWarnings)}`, ); }, ); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2502-insert-phase-state-update.test.cjs — consolidation epic #1969 (B4 #1973) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2502-insert-phase-state-update (consolidation epic #1969 B4 #1973)", () => { // allow-test-rule: source-text-is-the-product (see #2502) // 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. /** * Regression test for #2502: insert-phase does not update STATE.md's * next-phase recommendation after inserting a decimal phase. * * Root cause: insert-phase.md's update_project_state step only added a * "Roadmap Evolution" note to STATE.md, but never updated the "Current Phase" * / next-run recommendation to point at the newly inserted phase. * * Fix: insert-phase.md must include a step that updates STATE.md's next-phase * pointer (current_phase / next recommended run) to the newly inserted phase. */ 'use strict'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const INSERT_PHASE_PATH = path.join( __dirname, '..', 'gsd-core', 'workflows', 'insert-phase.md' ); describe('bug-2502: insert-phase must update STATE.md next-phase recommendation', () => { test('insert-phase.md exists', () => { assert.ok(fs.existsSync(INSERT_PHASE_PATH), 'insert-phase.md should exist'); }); test('insert-phase.md contains a STATE.md next-phase update instruction', () => { const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8'); // Must reference STATE.md and the concept of updating the next/current phase pointer const mentionsStateUpdate = ( /STATE\.md.{0,200}(next.phase|current.phase|next.run|recommendation)/is.test(content) || /(next.phase|current.phase|next.run|recommendation).{0,200}STATE\.md/is.test(content) ); assert.ok( mentionsStateUpdate, 'insert-phase.md must instruct updating STATE.md\'s next-phase recommendation to point to the newly inserted phase' ); }); test('insert-phase.md update_project_state step covers next-phase pointer', () => { const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8'); // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content const stepMatch = content.match(/([\s\S]*?)<\/step>/i); assert.ok(stepMatch, 'insert-phase.md must contain update_project_state step'); const stepContent = stepMatch[1]; const hasNextPhasePointerUpdate = ( /\bcurrent[_ -]?phase\b/i.test(stepContent) || /\bnext[_ -]?phase\b/i.test(stepContent) || /\bnext recommended run\b/i.test(stepContent) ); assert.ok( hasNextPhasePointerUpdate, 'insert-phase.md update_project_state step must update STATE.md\'s next-phase pointer (current_phase) to the inserted decimal phase' ); }); }); }); } // ───────────────────────────────────────────────────────────────────────────── // Regressions: issue #2316 — phase complete ghost REQ-ID / silent-discard defects // // ROADMAP.md may cite a REQ-ID in a phase's `**Requirements:**` line that was // never registered anywhere in REQUIREMENTS.md (neither its body nor its // Traceability table). The only existing cross-check compares REQUIREMENTS.md's // own body against its own Traceability table — it never consults the REQ-IDs // ROADMAP actually cites — so such a "ghost" REQ-ID is invisible: the checkbox/ // Traceability write for it silently matches nothing, and the command reports // `requirements_updated: true, warnings: []` identically to a run that wrote // something real. // // Contract asserted by #2316-3: `requirements_updated` reflects whether // REQUIREMENTS.md's content actually changed (before !== after write), not // merely whether the file existed in the transaction. // ───────────────────────────────────────────────────────────────────────────── /** * Build a 3-phase fixture: * Phase 01 "known" — cites KNOWN-01, which IS registered in REQUIREMENTS.md * (body checkbox + Traceability row). CONTROL phase. * Phase 02 "orphan" — cites ORPHAN-01, ORPHAN-02, which are registered * NOWHERE in REQUIREMENTS.md. BUG phase. * Phase 03 "tbd" — carries the literal `**Requirements**: TBD` placeholder * seeded by phase.add/-batch/-insert. BOUNDARY phase: * "TBD" itself must never be treated as a ghost REQ-ID. */ function build2316GhostReqFixture() { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2316-ghost-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); const phase1Dir = path.join(phasesDir, '01-known'); const phase2Dir = path.join(phasesDir, '02-orphan'); const phase3Dir = path.join(phasesDir, '03-tbd'); fs.mkdirSync(phase1Dir, { recursive: true }); fs.mkdirSync(phase2Dir, { recursive: true }); fs.mkdirSync(phase3Dir, { recursive: true }); fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ '# Requirements', '', '## Functional Requirements', '', '- [ ] **KNOWN-01** Known and registered requirement.', '', '## Traceability', '', '| Requirement | Phase | Status |', '|-------------|-------|--------|', '| KNOWN-01 | Phase 01 | Pending |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 01: Known', '- [ ] Phase 02: Orphan', '- [ ] Phase 03: Tbd', '', '### Phase 01: Known', '**Goal:** Build the known thing', '**Requirements:** KNOWN-01', '**Plans:** 1 plans', '', '### Phase 02: Orphan', '**Goal:** Build the orphan thing', '**Requirements:** ORPHAN-01, ORPHAN-02', '**Plans:** 1 plans', '', '### Phase 03: Tbd', '**Goal:** Not yet mapped', '**Requirements**: TBD', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Known | 0/1 | Not started | - |', '| 02. Orphan | 0/1 | Not started | - |', '| 03. Tbd | 0/1 | Not started | - |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Known', '**Status:** In progress', '**Completed Phases:** 0', '**Total Phases:** 3', '**Progress:** 0%', '', ].join('\n')); for (const [dir, n] of [[phase1Dir, '01'], [phase2Dir, '02'], [phase3Dir, '03']]) { fs.writeFileSync(path.join(dir, `${n}-01-PLAN.md`), '# Plan\nDo the work.\n'); fs.writeFileSync(path.join(dir, `${n}-01-SUMMARY.md`), '# Summary\nDone.\n'); } return { tmpDir, planDir }; } describe('issue #2316: phase complete ghost REQ-ID / silent-discard defects', () => { test( '#2316-1: ROADMAP-cited REQ-ID absent from REQUIREMENTS.md entirely → phase complete must report non-empty warnings naming the ghost ID(s)', () => { const { tmpDir } = build2316GhostReqFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '2'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; assert.ok( warnings.length > 0, `#2316-1 FAILED: expected non-empty warnings for ghost REQ-IDs ORPHAN-01/ORPHAN-02 ` + `(cited by ROADMAP Phase 02 but registered nowhere in REQUIREMENTS.md), got warnings:[].\n` + `Full output: ${output}`, ); assert.ok( warnings.some((w) => w.includes('ORPHAN-01')), `#2316-1 FAILED: warnings must name ORPHAN-01, got: ${JSON.stringify(warnings)}`, ); assert.ok( warnings.some((w) => w.includes('ORPHAN-02')), `#2316-1 FAILED: warnings must name ORPHAN-02, got: ${JSON.stringify(warnings)}`, ); assert.strictEqual( parsed.has_warnings, true, `#2316-1 FAILED: has_warnings must be true when ghost REQ-IDs are cited, got: ${JSON.stringify(parsed)}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2316-2 (control): a phase citing only a registered REQ-ID still ticks the checkbox + flips the Traceability row, and does not warn', () => { const { tmpDir, planDir } = build2316GhostReqFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); assert.deepStrictEqual( parsed.warnings || [], [], `#2316-2 FAILED: control phase (KNOWN-01 is registered) must not warn, got: ${JSON.stringify(parsed.warnings)}`, ); assert.strictEqual(parsed.has_warnings, false, '#2316-2 FAILED: has_warnings must be false for the control phase'); const reqContent = fs.readFileSync(path.join(planDir, 'REQUIREMENTS.md'), 'utf-8'); assert.ok( /-\s*\[x\]\s*\*\*KNOWN-01\*\*/.test(reqContent), `#2316-2 FAILED: checkbox for KNOWN-01 must be ticked.\nREQUIREMENTS.md:\n${reqContent}`, ); assert.ok( /\|\s*KNOWN-01\s*\|\s*Phase 01\s*\|\s*Complete\s*\|/.test(reqContent), `#2316-2 FAILED: Traceability row for KNOWN-01 must flip to Complete.\nREQUIREMENTS.md:\n${reqContent}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2316-3: requirements_updated must reflect whether REQUIREMENTS.md content actually changed, not merely that the file existed in the transaction', () => { // Case A (BUG): nothing written (ghost REQ-IDs only) → requirements_updated must be false. const ghost = build2316GhostReqFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '2'], ghost.tmpDir); const parsed = JSON.parse(output); assert.strictEqual( parsed.requirements_updated, false, `#2316-3a FAILED: requirements_updated must be false when REQUIREMENTS.md content did not change ` + `(ghost REQ-IDs ORPHAN-01/ORPHAN-02 match nothing to tick or flip).\nFull output: ${output}`, ); } finally { cleanup(ghost.tmpDir); } // Case B (CONTROL): a real write landed → requirements_updated must remain true. // Guards against a fix that over-broadens to "always false". const known = build2316GhostReqFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], known.tmpDir); const parsed = JSON.parse(output); assert.strictEqual( parsed.requirements_updated, true, `#2316-3b FAILED: requirements_updated must remain true when a real checkbox/Traceability write landed.\n` + `Full output: ${output}`, ); } finally { cleanup(known.tmpDir); } }, ); test( '#2316-7 (boundary): the literal "**Requirements**: TBD" placeholder seeded by phase.add/-batch/-insert must not produce a ghost-ID warning for the token "TBD"', () => { const { tmpDir } = build2316GhostReqFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '3'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; assert.ok( !warnings.some((w) => /\bTBD\b/.test(w)), `#2316-7 FAILED: the TBD placeholder must not be treated as a ghost REQ-ID, got warnings: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }, ); }); /** * Build a 2-phase fixture where REQUIREMENTS.md has ONE body section (name * controlled by `heading`) containing a REQ-ID (PRESET-01) that is absent from * the Traceability table. `heading` is the only variable across callers, so * this isolates the DEFERRED_HEADING_RE / milestone-aware-version-heading * behavior precisely. * * `milestone` (default `v1.3`, #2334 BLOCKER regression guard) is written * into STATE.md's `milestone:` frontmatter so a `## v ...` heading resolves * deterministically against the current milestone's MAJOR version instead of * silently exercising the "milestone unresolved" fail-safe path. Pass `null` * to omit the field entirely (fail-safe probe). * * `bodyProse`, when given, is inserted as its own paragraph directly under * `heading` and above the PRESET-01 line — used to pin the shipped * `templates/requirements.md` shape, where the deferred-ness lives in body * prose ("Deferred to future release...") rather than in the heading text. */ function build2316HeadingFixture(heading, milestone = 'v1.3', bodyProse = null) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2316-heading-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); const phase1Dir = path.join(phasesDir, '01-preset'); const phase2Dir = path.join(phasesDir, '02-next'); fs.mkdirSync(phase1Dir, { recursive: true }); fs.mkdirSync(phase2Dir, { recursive: true }); fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ '# Requirements', '', heading, '', ...(bodyProse ? [bodyProse, ''] : []), '- **PRESET-01** Body requirement missing from traceability table.', '', '## Traceability', '', '| Requirement | Phase | Status |', '|-------------|-------|--------|', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 01: Preset', '- [ ] Phase 02: Next', '', '### Phase 01: Preset', '**Goal:** Build preset', '**Requirements:** TBD', '**Plans:** 1 plans', '', '### Phase 02: Next', '**Goal:** whatever', '**Requirements:** TBD', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Preset | 0/1 | Not started | - |', '| 02. Next | 0/1 | Not started | - |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'STATE.md'), [ ...(milestone !== null ? ['---', `milestone: ${milestone}`, '---'] : []), '# State', '', '**Current Phase:** 01', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n')); for (const [dir, n] of [[phase1Dir, '01'], [phase2Dir, '02']]) { fs.writeFileSync(path.join(dir, `${n}-01-PLAN.md`), '# Plan\nDo the work.\n'); fs.writeFileSync(path.join(dir, `${n}-01-SUMMARY.md`), '# Summary\nDone.\n'); } return tmpDir; } describe('issue #2316 (Secondary A): DEFERRED_HEADING_RE\'s bare `v\\d+` alternative over-matches an active heading', () => { test( '#2316-4a: "## v1 Requirements" heading with a body REQ-ID missing from the Traceability table must still surface the body-vs-table warning (an active "v1" heading is not deferred)', () => { const tmpDir = build2316HeadingFixture('## v1 Requirements'); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; assert.ok( warnings.some((w) => /PRESET-01/.test(w) && /Traceability/i.test(w)), `#2316-4a FAILED: expected a body-vs-table Traceability warning naming PRESET-01 under the ` + `"## v1 Requirements" heading (the bare v\\d+ regex alternative wrongly treats it as deferred), ` + `got warnings: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2316-4b (control): "## Functional" heading (identical fixture, non-versioned heading name) must surface the same body-vs-table warning', () => { const tmpDir = build2316HeadingFixture('## Functional'); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; assert.ok( warnings.some((w) => /PRESET-01/.test(w) && /Traceability/i.test(w)), `#2316-4b FAILED: control heading "## Functional" must also warn for PRESET-01, got warnings: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2334 BLOCKER: "## v2 Requirements" heading with the shipped templates/requirements.md body prose ("Deferred to future release. Tracked but not in current roadmap."), milestone v1.3 -> the bare v\\d+ heading must be treated as deferred (SAME major-version mismatch: v2 != v1) and NOT surface the body-vs-table warning', () => { // Fixture copied VERBATIM from gsd-core/templates/requirements.md:35-37. const tmpDir = build2316HeadingFixture( '## v2 Requirements', 'v1.3', 'Deferred to future release. Tracked but not in current roadmap.', ); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); assert.strictEqual( traceWarnings.length, 0, `#2334 BLOCKER FAILED: "## v2 Requirements" under milestone v1.3 (the shipped template's OWN deferred ` + `section — #1159's original fix target) must stay suppressed, got: ${JSON.stringify(traceWarnings)}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2334 BLOCKER (milestone-unresolved fail-safe): "## v1 Requirements" heading with NO milestone field in STATE.md must fall back to the OLD pre-#2316-4a behavior and suppress the warning (a false "deferred" only ever suppresses, never spams)', () => { const tmpDir = build2316HeadingFixture('## v1 Requirements', /* milestone */ null); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); assert.strictEqual( traceWarnings.length, 0, `#2334 fail-safe FAILED: with no resolvable milestone, "## v1 Requirements" must fall back to treating ` + `v\\d+ as deferred (suppress), got: ${JSON.stringify(traceWarnings)}`, ); } finally { cleanup(tmpDir); } }, ); for (const heading of ['## Deferred', '## Backlog', '## Future']) { test( `#2316-5: genuinely deferred heading "${heading}" must still suppress the body-vs-table warning (regression guard — #1159 introduced this filter deliberately; the v\\d+ fix must not remove deferred/backlog/future detection)`, () => { const tmpDir = build2316HeadingFixture(heading); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); assert.strictEqual( traceWarnings.length, 0, `#2316-5 FAILED: heading "${heading}" must still be treated as deferred (no warning), ` + `got: ${JSON.stringify(traceWarnings)}`, ); } finally { cleanup(tmpDir); } }, ); } }); // ───────────────────────────────────────────────────────────────────────────── // #2334 HIGH 2 / HIGH 3: ghost-REQ-ID classification must probe the ACTUAL // write surfaces (checkbox + Traceability row, case-insensitive — mirroring // milestone.cts's notFound/hasRow/doneCheckbox classification), not set-diff // the deferred-filtered/case-sensitive bodyReqIds/tableReqIds indexes; and the // cited-REQ-ID tokenizer must be filtered to the REQ-ID shape before it ever // feeds a warning. // ───────────────────────────────────────────────────────────────────────────── function build2334GhostSurfaceFixture({ reqBody, roadmapRequirementsLine, traceabilityRows = [] }) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2334-ghost-surface-')); const planDir = path.join(tmpDir, '.planning'); const phasesDir = path.join(planDir, 'phases'); const phase1Dir = path.join(phasesDir, '01-preset'); const phase2Dir = path.join(phasesDir, '02-next'); fs.mkdirSync(phase1Dir, { recursive: true }); fs.mkdirSync(phase2Dir, { recursive: true }); fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ '# Requirements', '', ...reqBody, '', '## Traceability', '', '| Requirement | Phase | Status |', '|-------------|-------|--------|', ...traceabilityRows, '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 01: Preset', '- [ ] Phase 02: Next', '', '### Phase 01: Preset', '**Goal:** Build preset', `**Requirements**: ${roadmapRequirementsLine}`, '**Plans:** 1 plans', '', '### Phase 02: Next', '**Goal:** whatever', '**Requirements:** TBD', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Preset | 0/1 | Not started | - |', '| 02. Next | 0/1 | Not started | - |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'STATE.md'), [ '---', 'milestone: v1.3', '---', '# State', '', '**Current Phase:** 01', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n')); for (const [dir, n] of [[phase1Dir, '01'], [phase2Dir, '02']]) { fs.writeFileSync(path.join(dir, `${n}-01-PLAN.md`), '# Plan\nDo the work.\n'); fs.writeFileSync(path.join(dir, `${n}-01-SUMMARY.md`), '# Summary\nDone.\n'); } return tmpDir; } describe('issue #2334: ghost-REQ-ID classification must probe write surfaces, not lossy indexes', () => { test( '#2334 HIGH 2a: an ID under "## Deferred" whose checkbox this run TICKS must NOT be reported as a ghost', () => { const tmpDir = build2334GhostSurfaceFixture({ reqBody: ['## Deferred', '', '- [ ] **KNOWN-01**: some deferred requirement'], roadmapRequirementsLine: '[KNOWN-01]', }); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const reqContent = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok( /-\s*\[x\]\s*\*\*KNOWN-01\*\*/i.test(reqContent), `#2334 HIGH 2a FAILED (fixture invariant): checkbox for KNOWN-01 must have been ticked.\n${reqContent}`, ); assert.ok( !warnings.some((w) => /not registered anywhere/i.test(w) && /KNOWN-01/i.test(w)), `#2334 HIGH 2a FAILED: KNOWN-01 (checkbox ticked this run, under a Deferred heading) must not be ` + `reported as a ghost, got: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2334 HIGH 2b: a case-mismatched citation ("known-01" vs "**KNOWN-01**") whose write lands must NOT be reported as a ghost', () => { const tmpDir = build2334GhostSurfaceFixture({ reqBody: ['## Active', '', '- [ ] **KNOWN-01**: some active requirement'], roadmapRequirementsLine: '[known-01]', traceabilityRows: ['| KNOWN-01 | Phase 1 | Pending |'], }); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const reqContent = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok( /-\s*\[x\]\s*\*\*KNOWN-01\*\*/i.test(reqContent), `#2334 HIGH 2b FAILED (fixture invariant): checkbox must have been ticked.\n${reqContent}`, ); assert.ok( // eslint-disable-next-line local/no-unbounded-quantifier -- parses REQUIREMENTS.md the test itself wrote via build2334GhostSurfaceFixture, bounded fixed-size fixture, not adversarial input /\|\s*KNOWN-01\s*\|[^|]*\|\s*Complete\s*\|/i.test(reqContent), `#2334 HIGH 2b FAILED (fixture invariant): Traceability row must have flipped to Complete.\n${reqContent}`, ); assert.strictEqual(parsed.requirements_updated, true, '#2334 HIGH 2b FAILED: requirements_updated must be true'); assert.ok( !warnings.some((w) => /not registered anywhere/i.test(w)), `#2334 HIGH 2b FAILED: a case-mismatched citation whose write landed must not warn as a ghost, ` + `got: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2334 HIGH 3: the shipped templates/roadmap.md inline-HTML-comment Requirements line must not warn to register ""', () => { const tmpDir = build2334GhostSurfaceFixture({ reqBody: ['## Active', '', '- [ ] **REQ-01**: something'], roadmapRequirementsLine: '[REQ-01, REQ-02] ', traceabilityRows: ['| REQ-01 | Phase 1 | Pending |'], }); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; const junkTokens = ['']; for (const token of junkTokens) { assert.ok( !warnings.some((w) => w.includes(token)), `#2334 HIGH 3 FAILED: warnings must not name the non-REQ-ID token "${token}" from the inline HTML ` + `comment, got: ${JSON.stringify(warnings)}`, ); } // REQ-02 is a genuine, shape-valid, unregistered REQ-ID — it MUST // still be flagged (the shape filter must not over-broaden to // silence real ghosts). assert.ok( warnings.some((w) => /not registered anywhere/i.test(w) && /REQ-02/.test(w)), `#2334 HIGH 3 FAILED: REQ-02 (real, shape-valid, unregistered REQ-ID) must still be flagged as a ` + `ghost, got: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }, ); test( '#2334 HIGH 3 (boundary): "**Requirements:** None" must not warn to register the literal word "None"', () => { const tmpDir = build2334GhostSurfaceFixture({ reqBody: ['## Active'], roadmapRequirementsLine: 'None', }); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; assert.ok( !warnings.some((w) => /\bNone\b/.test(w)), `#2334 HIGH 3 boundary FAILED: "None" must not be treated as a cited REQ-ID, got: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }, ); }); // ─── #2572: phase-SUMMARY artifact↔disk advisory at phase completion ───────── // // A SUMMARY asserts "I created these files". Until #2572 nothing checked that // claim for phase summaries — `verify-summary` existed but was only ever // pointed at `.planning/research/SUMMARY.md`, so an interrupted or // over-reported phase counted toward 100% silently. // // The advisory joins the existing `warnings[]` channel of `phase complete` // (rendered by execute-phase.md's "If has_warnings is true" step). It is // advisory ONLY: the completion GATE is readVerificationStatus, untouched here. function build2572SummaryArtifactFixture() { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2572-artifact-')); const planDir = path.join(tmpDir, '.planning'); const dirtyDir = path.join(planDir, 'phases', '01-dirty'); const cleanDir = path.join(planDir, 'phases', '02-clean'); fs.mkdirSync(dirtyDir, { recursive: true }); fs.mkdirSync(cleanDir, { recursive: true }); fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); // Only these two land on disk. fs.writeFileSync(path.join(tmpDir, 'src/landed-one.ts'), 'x\n'); fs.writeFileSync(path.join(tmpDir, 'src/landed-two.ts'), 'x\n'); fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ '# Roadmap', '', '- [ ] Phase 01: Dirty', '- [ ] Phase 02: Clean', '', '### Phase 01: Dirty', '**Goal:** Ship the dirty thing', '**Plans:** 1 plans', '', '### Phase 02: Clean', '**Goal:** Ship the clean thing', '**Plans:** 1 plans', '', '## Progress', '', '| Phase | Plans Complete | Status | Completed |', '|-------|----------------|--------|-----------|', '| 01. Dirty | 0/1 | Not started | - |', '| 02. Clean | 0/1 | Not started | - |', '', ].join('\n')); fs.writeFileSync(path.join(planDir, 'STATE.md'), [ '# State', '', '**Current Phase:** 01', '**Current Phase Name:** Dirty', '**Status:** In progress', '**Completed Phases:** 0', '**Total Phases:** 2', '**Progress:** 0%', '', ].join('\n')); fs.writeFileSync(path.join(dirtyDir, '01-01-PLAN.md'), '# Plan\nDo the work.\n'); // Frontmatter uses the shipped template's YAML flow sequence on purpose: the // bracket artifact it once produced must not surface as a phantom warning. fs.writeFileSync(path.join(dirtyDir, '01-01-SUMMARY.md'), [ '---', 'phase: 01-dirty', 'key-files:', ' created: [src/landed-one.ts, src/never-landed.ts]', 'status: complete', '---', '', '# Phase 1 Summary', '', 'Created `src/landed-one.ts` and `src/never-landed.ts`.', '', ].join('\n')); fs.writeFileSync(path.join(cleanDir, '02-01-PLAN.md'), '# Plan\nDo the work.\n'); fs.writeFileSync(path.join(cleanDir, '02-01-SUMMARY.md'), [ '---', 'phase: 02-clean', 'key-files:', ' created: [src/landed-two.ts]', 'status: complete', '---', '', '# Phase 2 Summary', '', 'Created `src/landed-two.ts`.', '', ].join('\n')); return { tmpDir }; } describe('#2572: phase complete warns when a SUMMARY claims files that never landed', () => { test('#2572-1: a SUMMARY naming a file that is not on disk produces a warning at completion', () => { const { tmpDir } = build2572SummaryArtifactFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; assert.ok( warnings.some((w) => w.includes('src/never-landed.ts')), `#2572-1 FAILED: expected a warning naming src/never-landed.ts, got: ${JSON.stringify(warnings)}`, ); assert.ok( warnings.some((w) => w.includes('01-01-SUMMARY.md')), `#2572-1 FAILED: the warning must name the SUMMARY it came from, got: ${JSON.stringify(warnings)}`, ); assert.strictEqual(parsed.has_warnings, true, '#2572-1 FAILED: has_warnings must be true'); } finally { cleanup(tmpDir); } }); test('#2572-2: the advisory is ADVISORY — completion still succeeds and reports the phase complete', () => { const { tmpDir } = build2572SummaryArtifactFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir); const parsed = JSON.parse(output); assert.strictEqual(parsed.completed_phase, '1', '#2572-2 FAILED: completion must not be blocked by the advisory'); assert.strictEqual(parsed.state_updated, true, '#2572-2 FAILED: STATE.md must still be written'); } finally { cleanup(tmpDir); } }); test('#2572-3 (control): a phase whose SUMMARY files all exist emits NO artifact warning', () => { const { tmpDir } = build2572SummaryArtifactFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '2'], tmpDir); const parsed = JSON.parse(output); const warnings = parsed.warnings || []; assert.ok( !warnings.some((w) => /not on disk/.test(w)), `#2572-3 FAILED: a clean phase must emit no artifact advisory. This is the ` + `"absent for a clean one" half — and the fixture references a real '/'-bearing ` + `path, so silence here is earned, not vacuous. Got: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }); test('#2572-4: the shipped template frontmatter flow sequence produces no phantom "[path" warning', () => { const { tmpDir } = build2572SummaryArtifactFixture(); try { const { output } = runVerifiedPhaseComplete(['phase', 'complete', '2'], tmpDir); const warnings = JSON.parse(output).warnings || []; assert.ok( !warnings.some((w) => w.includes('[src/')), `#2572-4 FAILED (#2685 Blocker 1): a YAML flow sequence in frontmatter must not ` + `leak a bracket-prefixed candidate. Got: ${JSON.stringify(warnings)}`, ); } finally { cleanup(tmpDir); } }); }); // Outer scope shared by the two fold blocks below (deliberately NOT module-scope: // this file already declares a distinct, 3-arg `runVerifiedPhaseComplete(args, tmpDir, env)` // at line 54 used throughout the rest of the file; nesting here avoids shadowing it). // // IIFE, not a bare `{ }` block: this file has no top-level 'use strict', so a plain // block is sloppy-mode and Annex B function-hoisting semantics apply — a `function` // declared directly inside a bare block still leaks out and REASSIGNS the enclosing // (module-scope) `runVerifiedPhaseComplete` var the moment this block runs, clobbering // the real one at line 54 for every call site in the file (test() bodies are deferred // and all run after this synchronous top-level code, so every caller ends up hitting // this one). Wrapping in a strict-mode function expression suppresses Annex B leakage, // matching how the two original un-consolidated copies were each scoped inside a // strict-mode `describe(() => { 'use strict'; ... })` arrow function body. (function () { 'use strict'; /** * Write a passed-VERIFICATION marker for the phase, then run `phase complete N`. * Mirrors phase.test.cjs's writePassedVerificationForPhase: a `-VERIFICATION.md` * with `status: passed` frontmatter. Requires the phase directory to exist. * * Shared by the folded:issue-2945-phase-complete-checkbox-rollback and * folded:issue-2949-phase-complete-stage3-sentinel blocks below — both fold sources * defined this same helper independently; consolidated to one definition (PR #3339 * review, Fowler-baseline duplication finding) since both bodies were functionally * identical modulo variable naming. */ function runVerifiedPhaseComplete(args, tmpDir) { const argv = Array.isArray(args) ? args : args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g); const completeIdx = argv.findIndex((t, i) => t === 'complete' && argv[i - 1] === 'phase'); const phase = argv[completeIdx + 1]; const phasesDir = path.join(tmpDir, '.planning', 'phases'); const wanted = parseInt(String(phase).replace(/^0+/, ''), 10); const phaseDirName = fs.readdirSync(phasesDir).find((name) => { const m = name.match(/^(\d+)/); return m && parseInt(m[1], 10) === wanted; }); if (!phaseDirName) throw new Error(`no phase directory for phase ${phase}`); fs.writeFileSync( path.join(phasesDir, phaseDirName, `${phase}-VERIFICATION.md`), ['---', 'status: passed', '---', '', '# Verification', ''].join('\n'), ); return runGsdTools(args, tmpDir); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/issue-2945-phase-complete-checkbox-rollback.test.cjs — H3 Wave 7 test-hygiene sweep (#3339) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe('folded:issue-2945-phase-complete-checkbox-rollback (#3339)', () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; /** * Regression test for #2945 — `phase complete`'s inline REQUIREMENTS.md checkbox * flip is traceability-blind: it flips `- [ ] **REQ-ID**` → `- [x]` unconditionally, * then attempts the traceability-row write, and KEEPS the flip when the row exists * but rejects the write (Out/Deferred/Blocked). The sibling `requirements.mark-complete` * got the #2788 defect-2 rollback; `cmdPhaseComplete`'s inline copy did not. * * The fix ports the rollback from `cmdRequirementsMarkComplete` (src/milestone.cts): * capture beforeCheckbox, track whether the row write actually changed (tableHit), and * restore beforeCheckbox when the row EXISTS but rejects the write. * * Matrix: .gsd/bug/fix/2945-phase-complete-checkbox-rollback/50-test-matrix.md */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { createTempProject, cleanup } = require('./helpers.cjs'); // runVerifiedPhaseComplete is defined once, hoisted above both fold blocks (see // the shared helper preceding the "Folded from tests/issue-2945-..." banner); // this block closes over that module-scope definition. describe('phase complete checkbox rollback (#2945)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('gsd-2945-'); }); afterEach(() => { cleanup(tmpDir); }); /** * Scaffold a single-phase project whose ROADMAP cites the given REQ-IDs, with a * REQUIREMENTS.md whose traceability table rows carry the given statuses, then run * `phase complete 1`. Returns the REQUIREMENTS.md content after completion. */ function completeWithRows(reqIds, rowStatuses) { const reqList = reqIds.join(', '); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n- [ ] Phase 1: The phase\n\n### Phase 1: The phase\n**Goal:** do it\n**Requirements:** ${reqList}\n**Plans:** 1 plans\n`, ); const reqLines = reqIds.map((id) => `- [ ] **${id}**: a requirement`).join('\n'); const tableRows = reqIds.map((id, i) => `| ${id} | Phase 1 | ${rowStatuses[i]} |`).join('\n'); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements\n\n## v1 Requirements\n\n${reqLines}\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n${tableRows}\n`, ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** The phase\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`, ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-the-phase'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); return fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); } test('deferredRowRollsBackCheckbox', () => { // Row 1 (failing-first regression): a Deferred row rejects the Status write, so the // checkbox must NOT flip (it stays [ ]), and the row must stay Deferred. const req = completeWithRows(['DEF-01'], ['Deferred']); assert.ok(req.includes('- [ ] **DEF-01**'), 'checkbox must stay [ ] when the row is Deferred (no silent divergence)'); assert.ok(/^\| DEF-01 \| Phase 1 \| Deferred \|/m.test(req), 'row must stay Deferred'); assert.ok(!req.includes('- [x] **DEF-01**'), 'checkbox must NOT have flipped to [x]'); }); test('blockedRowRollsBackCheckbox', () => { // Row 2: an Out/Blocked row likewise rejects the write → checkbox stays [ ]. const req = completeWithRows(['BLK-01'], ['Blocked']); assert.ok(req.includes('- [ ] **BLK-01**'), 'checkbox must stay [ ] when the row is Blocked'); assert.ok(/^\| BLK-01 \| Phase 1 \| Blocked \|/m.test(req), 'row must stay Blocked'); assert.ok(!req.includes('- [x] **BLK-01**'), 'checkbox must NOT have flipped to [x]'); }); test('pendingRowStillFlipsAndAdvances', () => { // Row 3 (negative-space / unchanged forward behavior): a Pending row accepts the write, // so the checkbox MUST flip to [x] and the row MUST advance to Complete. An over-broad // rollback would break this. const req = completeWithRows(['FWD-01'], ['Pending']); assert.ok(req.includes('- [x] **FWD-01**'), 'checkbox MUST flip to [x] for a Pending (forward) row'); assert.ok(/^\| FWD-01 \| Phase 1 \| Complete \|/m.test(req), 'row MUST advance to Complete'); }); test('noRowStillFlipsCheckbox', () => { // Row 4 (acceptance #3): a cited REQ-ID with NO traceability row → checkbox still flips // (nothing to disagree with). The rollback only fires when a row EXISTS and rejects. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n\n- [ ] Phase 1: The phase\n\n### Phase 1: The phase\n**Goal:** do it\n**Requirements:** NOROW-01\n**Plans:** 1 plans\n`, ); // REQUIREMENTS.md with the checkbox but NO traceability table. fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), `# Requirements\n\n## v1 Requirements\n\n- [ ] **NOROW-01**: a requirement with no traceability row\n`, ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 01\n**Current Phase Name:** The phase\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`, ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-the-phase'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); const result = runVerifiedPhaseComplete('phase complete 1', tmpDir); assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); assert.ok(req.includes('- [x] **NOROW-01**'), 'checkbox MUST flip to [x] when no traceability row exists'); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/issue-2949-phase-complete-stage3-sentinel.test.cjs — H3 Wave 7 test-hygiene sweep (#3339) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe('folded:issue-2949-phase-complete-stage3-sentinel (#3339)', () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; /** * Regression test for #2949 — `phase complete`'s stage-3 lowest-outstanding-override * loop admits unchecked `0.x` backlog sentinel rows as `next_phase`, corrupting STATE.md * and desyncing `current_phase` from `current_phase_name`. * * Root cause: `src/phase.cts` stage-3 condition `!isChecked && comparePhaseNum(cbm[2], phaseNum) < 0` * has no sentinel filter, so `comparePhaseNum("0.1","12") === -12` admits the `0.x` backlog row. * The fix adds `&& !isSentinelPhaseId(cbm[2])` (reusing the existing zero-caller predicate), which * excludes both sentinel ranges (0 and 999). Stage-3 only here — PR #2815 (in-flight) covers * stages 1-2 for #2786. * * Matrix: .gsd/bug/fix/2949-phase-complete-stage3-sentinel-filter/50-test-matrix.md */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { createTempProject, cleanup } = require('./helpers.cjs'); // runVerifiedPhaseComplete is defined once, in the outer scope shared by this block // and the folded:issue-2945-phase-complete-checkbox-rollback block above (see the // shared helper preceding that block's banner comment); this block closes over it. describe('phase complete stage-3 sentinel filter (#2949)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('gsd-2949-'); }); afterEach(() => { cleanup(tmpDir); }); /** Scaffold a phase dir with one executed plan (PLAN + SUMMARY). */ function scaffoldPhase(slug, planNum) { const dir = path.join(tmpDir, '.planning', 'phases', slug); fs.mkdirSync(dir, { recursive: true }); const padded = String(planNum).padStart(2, '0'); fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '# Plan'); fs.writeFileSync(path.join(dir, `${padded}-01-SUMMARY.md`), '# Summary'); } test('zeroXSentinelDoesNotBecomeNextPhase', () => { // Row 1 (failing-first regression): completing the last real phase with an unchecked // 0.x backlog sentinel row present must NOT select the sentinel as next_phase. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 0.1: Backlog sentinel item** — deferred work - [x] **Phase 11: First phase** (completed 2025-01-01) - [ ] **Phase 12: Last phase** ### Phase 11: First phase **Goal:** first **Plans:** 1 plans ### Phase 12: Last phase **Goal:** last **Plans:** 1 plans `, ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 12\n**Current Phase Name:** Last phase\n**Status:** In progress\n**Current Plan:** 12-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 12\n`, ); scaffoldPhase('11-first-phase', 11); scaffoldPhase('12-last-phase', 12); const result = runVerifiedPhaseComplete('phase complete 12', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(output.completed_phase, '12'); assert.strictEqual(output.is_last_phase, true, '0.x sentinel must not prevent milestone completion (is_last_phase=true)'); assert.strictEqual(output.next_phase, null, '0.x sentinel must not be selected as next_phase'); // STATE.md current_phase must stay on the completed phase (12), not advance to 0.1. const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(!/\*\*Current Phase:\*\*\s*0\.1/i.test(state), 'STATE.md current_phase must NOT have advanced to the 0.x sentinel'); }); test('realLowerOutstandingPhaseStillSelected', () => { // Row 2 (#2028 non-regression): a REAL lower-numbered unchecked phase must STILL be // selected as next_phase. The sentinel filter must not over-broaden to real phases. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 9: Skipped-then-resumed phase** - [ ] **Phase 10: Current phase** ### Phase 9: Skipped-then-resumed phase **Goal:** nine **Plans:** 1 plans ### Phase 10: Current phase **Goal:** ten **Plans:** 1 plans `, ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 10\n**Current Phase Name:** Current phase\n**Status:** In progress\n**Current Plan:** 10-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 10\n`, ); scaffoldPhase('09-skipped-then-resumed-phase', 9); scaffoldPhase('10-current-phase', 10); const result = runVerifiedPhaseComplete('phase complete 10', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); // A real lower phase (9) IS selected — the #2028 out-of-order behavior is preserved. // next_phase may be padded ("09") or unpadded ("9"); compare numerically. assert.strictEqual(output.is_last_phase, false, 'a real lower outstanding phase must keep is_last_phase=false'); const nextNum = parseInt(String(output.next_phase), 10); assert.strictEqual(nextNum, 9, `real lower phase 9 must be selected as next_phase (got ${output.next_phase})`); }); test('zeroXSentinelNoCurrentPhaseDesync', () => { // Row 3 (acceptance #3/#4): current_phase and current_phase_name must not desync when // a 0.x sentinel is present and the milestone completes. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 0.1: Backlog** - [ ] **Phase 5: Only phase** ### Phase 5: Only phase **Goal:** five **Plans:** 1 plans `, ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 5\n**Current Phase Name:** Only phase\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 5\n`, ); scaffoldPhase('05-only-phase', 5); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, true, 'milestone completes despite the 0.x sentinel'); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); // current_phase must NOT have advanced to 0.1 (no desync into the sentinel). assert.ok(!/\*\*Current Phase:\*\*\s*0\.1/i.test(state), 'no desync: current_phase did not advance to 0.1'); }); test('checkedZeroXSentinelIrrelevant', () => { // Row 4 (boundary): a CHECKED 0.x sentinel is irrelevant — completing the last real phase // still completes the milestone. fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [x] **Phase 0.1: Already-done backlog item** - [ ] **Phase 3: Last phase** ### Phase 3: Last phase **Goal:** three **Plans:** 1 plans `, ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# State\n\n**Current Phase:** 3\n**Current Phase Name:** Last phase\n**Status:** In progress\n**Current Plan:** 03-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 3\n`, ); scaffoldPhase('03-last-phase', 3); const result = runVerifiedPhaseComplete('phase complete 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, true, 'checked sentinel is irrelevant; milestone completes'); assert.strictEqual(output.next_phase, null); }); }); }); } })(); // #3350 — phase complete: a merely-positionally-next higher phase heading // (stage 2) must not mask a genuinely-outstanding lower phase (stage 3), and // STATE.md frontmatter's current_phase/current_phase_name must stay paired. // Matrix: .gsd/bug/fix-3350-phase-complete-name-desync/50-test-matrix.md describe('phase complete lowest-outstanding vs positional-next (#3350)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject('gsd-3350-'); }); afterEach(() => { cleanup(tmpDir); }); /** Scaffold a phase dir with one executed plan (PLAN + SUMMARY). */ function scaffoldPhase(slug, planNum) { const dir = path.join(tmpDir, '.planning', 'phases', slug); fs.mkdirSync(dir, { recursive: true }); const padded = String(planNum).padStart(2, '0'); fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '# Plan'); fs.writeFileSync(path.join(dir, `${padded}-01-SUMMARY.md`), '# Summary'); } const statePath = () => path.join(tmpDir, '.planning', 'STATE.md'); /** * Row-4 roadmap: phase 5 is completed (dir on disk); phases 3 and 4 are * outstanding (unchecked, never executed, no dirs — optional); higher * headings 6 and 7 are pre-declared with no dirs (stage-1 miss, stage-2 hit). */ function writeRow4Roadmap({ withLowerDirs = false, withHigherDir = false } = {}) { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap - [ ] **Phase 3: Attestation Freeze** - [ ] **Phase 4: Corpus Maturation** - [ ] **Phase 5: Harness Mechanization** - [ ] **Phase 6: Oracle Re-Hardening** - [ ] **Phase 7: Recall Monitoring** ### Phase 3: Attestation Freeze **Goal:** three **Plans:** 1 plans ### Phase 4: Corpus Maturation **Goal:** four **Plans:** 1 plans ### Phase 5: Harness Mechanization **Goal:** five **Plans:** 1 plans ### Phase 6: Oracle Re-Hardening **Goal:** six **Plans:** 1 plans ### Phase 7: Recall Monitoring **Goal:** seven **Plans:** 1 plans `, ); scaffoldPhase('05-harness-mechanization', 5); if (withLowerDirs) { scaffoldPhase('03-attestation-freeze', 3); scaffoldPhase('04-corpus-maturation', 4); } if (withHigherDir) { scaffoldPhase('06-oracle-re-hardening', 6); } } /** Explicit-field STATE.md (body fields present — the 0xdhx fixture shape). */ function writeExplicitState() { fs.writeFileSync( statePath(), `# State **Current Phase:** 5 **Current Phase Name:** Harness Mechanization **Status:** In progress **Current Plan:** 05-01 **Last Activity:** 2025-01-01 **Last Activity Description:** Working on phase 5 `, ); } /** * Narrative-prose STATE.md (NO body fields) with frontmatter parked on the * just-completed phase — the filed #3350 shape: without the pairing override * current_phase stays at 5 while current_phase_name advances. */ function writeNarrativeState() { fs.writeFileSync( statePath(), `--- current_phase: 5 current_phase_name: Harness Mechanization --- # State We are wrapping up phase 5 of the milestone; the next actionable phase is still to be determined by the roadmap. `, ); } function parseFrontmatterField(content, key) { const m = content.match(new RegExp(`^${key}:\\s*(.*)$`, 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : null; } test('lowerOutstandingBeatsHigherHeading', () => { // Row 4 (failing-first): completing 5 with outstanding 3/4 AND pre-declared // higher headings 6/7 must pick the lowest outstanding phase (3), not the // positionally-next heading (6). writeRow4Roadmap(); writeExplicitState(); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(output.completed_phase, '5'); assert.strictEqual(output.is_last_phase, false, 'a higher phase exists — not last'); assert.strictEqual( parseInt(String(output.next_phase), 10), 3, `lowest outstanding phase 3 must be next_phase (got ${output.next_phase})`, ); assert.strictEqual(output.next_phase_name, 'attestation-freeze'); }); test('narrativeStateFrontmatterPairing', () => { // Row 4 + acceptance #2/#5 (failing-first): with a narrative STATE.md and // frontmatter parked on the completed phase, BOTH frontmatter fields must // describe the resolved next phase (3) after the write — never a split. writeRow4Roadmap(); writeNarrativeState(); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(parseInt(String(output.next_phase), 10), 3); const state = fs.readFileSync(statePath(), 'utf-8'); const fmPhase = parseFrontmatterField(state, 'current_phase'); const fmName = parseFrontmatterField(state, 'current_phase_name'); assert.ok(fmPhase !== null, 'frontmatter current_phase must exist'); assert.ok(fmName !== null, 'frontmatter current_phase_name must exist'); assert.strictEqual( parseInt(fmPhase, 10), 3, `current_phase must advance to the resolved next phase 3 (got ${fmPhase})`, ); assert.match(fmName, /attestation freeze/i, `current_phase_name must name phase 3 (got ${fmName})`); }); test('higherStillWinsWhenNoLowerOutstanding', () => { // Row 2 non-regression (acceptance #4): N+k really is the correct next phase // when no lower phase is genuinely outstanding. writeRow4Roadmap(); writeExplicitState(); // Check phases 3 and 4 off — the higher heading is then the right answer. const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); fs.writeFileSync( roadmapPath, fs.readFileSync(roadmapPath, 'utf-8') .replace('- [ ] **Phase 3: Attestation Freeze**', '- [x] **Phase 3: Attestation Freeze**') .replace('- [ ] **Phase 4: Corpus Maturation**', '- [x] **Phase 4: Corpus Maturation**'), ); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(parseInt(String(output.next_phase), 10), 6, 'positionally-next 6 wins when no lower phase is outstanding'); assert.strictEqual(output.is_last_phase, false); }); test('lowerOnlyStillSelected', () => { // Row 3 non-regression (#2028 original shape): lower outstanding, no higher. writeRow4Roadmap(); writeExplicitState(); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); let roadmap = fs.readFileSync(roadmapPath, 'utf-8'); roadmap = roadmap .replace('- [ ] **Phase 6: Oracle Re-Hardening**\n', '') .replace('- [ ] **Phase 7: Recall Monitoring**\n', '') .replace('### Phase 6: Oracle Re-Hardening\n**Goal:** six\n**Plans:** 1 plans\n\n', '') .replace('### Phase 7: Recall Monitoring\n**Goal:** seven\n**Plans:** 1 plans\n', ''); fs.writeFileSync(roadmapPath, roadmap); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(parseInt(String(output.next_phase), 10), 3, '#2028 behavior preserved'); assert.strictEqual(output.is_last_phase, false); }); test('nothingOutstandingStillCompletesMilestone', () => { // Row 1 non-regression: no higher phase (heading or dir) and no unchecked // lower phase → is_last_phase stays true. Higher HEADINGS alone keep // is_last_phase false (stage 2 matches headings regardless of checkbox // state), so this fixture drops them entirely. writeRow4Roadmap(); writeExplicitState(); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); let roadmap = fs.readFileSync(roadmapPath, 'utf-8'); roadmap = roadmap .replace('- [ ] **Phase 3: Attestation Freeze**', '- [x] **Phase 3: Attestation Freeze**') .replace('- [ ] **Phase 4: Corpus Maturation**', '- [x] **Phase 4: Corpus Maturation**') .replace('- [ ] **Phase 6: Oracle Re-Hardening**\n', '') .replace('- [ ] **Phase 7: Recall Monitoring**\n', '') .replace('### Phase 6: Oracle Re-Hardening\n**Goal:** six\n**Plans:** 1 plans\n\n', '') .replace('### Phase 7: Recall Monitoring\n**Goal:** seven\n**Plans:** 1 plans\n', ''); fs.writeFileSync(roadmapPath, roadmap); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(output.is_last_phase, true, 'nothing outstanding — milestone completes'); assert.strictEqual(output.next_phase, null); }); test('sentinelStillExcludedWithHigherPresent', () => { // Acceptance #3: the #2949 sentinel filter keeps working alongside the // ungate — an unchecked 0.x backlog row never becomes next_phase. writeRow4Roadmap(); writeExplicitState(); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); fs.writeFileSync( roadmapPath, '- [ ] **Phase 0.1: Backlog sentinel item**\n' + fs.readFileSync(roadmapPath, 'utf-8'), ); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual( parseInt(String(output.next_phase), 10), 3, `real phase 3 (not the 0.1 sentinel) is next_phase (got ${output.next_phase})`, ); }); test('explicitBodyFieldsMoveTogether', () => { // Row 4 explicit-field variant (0xdhx fixture A): the body fields AND the // frontmatter must move to phase 3 together — never both-wrong on 6. writeRow4Roadmap(); writeExplicitState(); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual(parseInt(String(output.next_phase), 10), 3); const state = fs.readFileSync(statePath(), 'utf-8'); assert.ok(/\*\*Current Phase:\*\*\s*3\b/.test(state), `body Current Phase must be 3, got: ${state.match(/\*\*Current Phase:\*\*.*/)?.[0]}`); assert.match(state, /\*\*Current Phase Name:\*\*\s*Attestation Freeze/); const fmPhase = parseFrontmatterField(state, 'current_phase'); const fmName = parseFrontmatterField(state, 'current_phase_name'); assert.strictEqual(parseInt(fmPhase, 10), 3, `frontmatter current_phase must derive to 3 (got ${fmPhase})`); assert.match(fmName, /attestation freeze/i, `frontmatter current_phase_name must name phase 3 (got ${fmName})`); }); test('diskPresentHigherStillLosesToLowerOutstanding', () => { // Row 4 stage-1 variant (failing-first): a higher phase directory ON DISK // (stage-1 hit) also must not mask an outstanding lower phase. writeRow4Roadmap({ withHigherDir: true }); writeExplicitState(); const result = runVerifiedPhaseComplete('phase complete 5', tmpDir); assert.ok(result.success, `Command failed: ${result.error || result.output}`); const output = JSON.parse(result.output); assert.strictEqual( parseInt(String(output.next_phase), 10), 3, `lowest outstanding phase 3 beats the on-disk higher phase 6 (got ${output.next_phase})`, ); }); }); // ─── #3572: phase remove must not prepend a second frontmatter block ────────── describe('bug #3572: phase remove must not corrupt STATE.md into two frontmatter blocks', () => { const ISSUE_STATE = [ '---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'milestone_name: First', 'current_phase: 2', 'current_phase_name: Feature', 'status: executing', 'stopped_at: Phase 1 complete', 'last_updated: "2026-08-16T10:00:00.000Z"', 'last_activity: 2026-08-16', 'last_activity_desc: "Phase 1 complete."', 'progress:', ' total_phases: 2', ' completed_phases: 1', ' total_plans: 2', ' completed_plans: 1', ' percent: 50', '---', '', '# Project State', '', 'Some prose here that must survive.', '', ].join('\n'); const TWO_PHASE_ROADMAP = '# Roadmap\n\n## Milestone v1.0\n\n### Phase 1: Setup\n**Goal:** Bootstrap the project.\n\n### Phase 2: Feature\n**Goal:** Ship the feature.\n'; function setupProject(t, stateMd = ISSUE_STATE, eol = '\n') { const tmpDir = createTempProject('gsd-3572-'); fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), TWO_PHASE_ROADMAP); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), stateMd.split('\n').join(eol), ); t.after(() => cleanup(tmpDir)); return tmpDir; } function fenceLineCount(content) { return content.split(/\r?\n/).filter((l) => l.trim() === '---').length; } test('#3572: phase remove of an inserted decimal phase keeps STATE.md a single frontmatter block', (t) => { const tmpDir = setupProject(t); // The issue's exact sequence: insert creates the directory; remove then has a // targetDir !== null, and the body lacks Total Phases/of-N — the trigger. let r = runGsdTools('phase insert 1 "Inserted probe"', tmpDir); assert.ok(r.success, `phase insert failed: ${r.error}`); r = runGsdTools('phase remove 1.1', tmpDir); assert.ok(r.success, `phase remove failed: ${r.error}`); assert.strictEqual(JSON.parse(r.output).state_updated, true, 'the #2640 resync must still happen'); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(after.startsWith('---\n') || after.startsWith('---\r\n'), 'file must still OPEN with the frontmatter fence'); assert.strictEqual(fenceLineCount(after), 2, `exactly one frontmatter block (2 fence lines); got ${fenceLineCount(after)}:\n${after.slice(0, 400)}`); assert.strictEqual((after.match(/gsd_state_version/g) || []).length, 1, 'exactly one gsd_state_version — no second derived block'); assert.ok(after.includes('Some prose here that must survive.'), 'body prose must survive verbatim'); assert.match(after, /^Total Phases:\s*\d+$/m, 'the inserted count field must live in the BODY (line-start), not before the first fence'); // Pinned value: the body field counts DIRECTORIES on disk (0 after removing // the only directory); the frontmatter progress block derives from ROADMAP // (2 below) — the two counters have different provenance by design (#2640/#2528). assert.match(after, /^Total Phases:\s*0$/m, 'body field = remaining on-disk phase directories'); const fm = after.match(/total_phases:\s*(\d+)/); assert.ok(fm, 'frontmatter progress.total_phases present'); assert.strictEqual(fm[1], '2', `total_phases must resync to the 2 remaining roadmap phases; got ${fm[1]}`); }); test('#3572: integer-phase remove with directory also stays single-block (strengthens #2640)', (t) => { const tmpDir = setupProject(t); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature'), { recursive: true }); const r = runGsdTools('phase remove 2', tmpDir); assert.ok(r.success, `phase remove failed: ${r.error}`); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.strictEqual(fenceLineCount(after), 2, `single frontmatter block; got ${fenceLineCount(after)}`); assert.ok(after.startsWith('---'), 'opens with the fence'); assert.ok(after.includes('Some prose here that must survive.'), 'body prose preserved'); }); test('#3572: existing body Total Phases decremented in place, single block', (t) => { const stateWithField = ISSUE_STATE.replace( 'Some prose here that must survive.', 'Total Phases: 2\n\nSome prose here that must survive.', ); const tmpDir = setupProject(t, stateWithField); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature'), { recursive: true }); const r = runGsdTools('phase remove 2', tmpDir); assert.ok(r.success, `phase remove failed: ${r.error}`); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.strictEqual(fenceLineCount(after), 2, 'single frontmatter block'); const bodyCounts = after.match(/^Total Phases:\s*(\d+)$/gm) || []; assert.strictEqual(bodyCounts.length, 1, `exactly one Total Phases field; got ${bodyCounts.length}`); assert.match(bodyCounts[0], /^Total Phases:\s*1$/, `field decremented to 1; got ${bodyCounts[0]}`); }); test('#3572: frontmatter-less STATE.md gets the field at content start', (t) => { const tmpDir = setupProject(t, '# Bare state\n\nNo fences at all here.\n'); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature'), { recursive: true }); const r = runGsdTools('phase remove 2', tmpDir); assert.ok(r.success, `phase remove failed: ${r.error}`); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.match(after, /^Total Phases:\s*\d+$/m, 'field lands at content start when the whole file is body'); assert.ok(after.includes('No fences at all here.'), 'original body preserved'); }); test('#3572: CRLF STATE.md stays single-block with CRLF preserved', (t) => { const tmpDir = setupProject(t, ISSUE_STATE, '\r\n'); let r = runGsdTools('phase insert 1 "Inserted probe"', tmpDir); assert.ok(r.success, `phase insert failed: ${r.error}`); r = runGsdTools('phase remove 1.1', tmpDir); assert.ok(r.success, `phase remove failed: ${r.error}`); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.strictEqual(fenceLineCount(after), 2, `single frontmatter block under CRLF; got ${fenceLineCount(after)}`); assert.ok(after.includes('Some prose here that must survive.'), 'body prose preserved'); assert.match(after, /^Total Phases:\s*\d+\r?$/m, 'count field present in body'); }); test('#3572: ROADMAP-only phase removal leaves STATE.md untouched (issue control)', (t) => { const tmpDir = setupProject(t); const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const r = runGsdTools('phase remove 2', tmpDir); // phase 2 has NO directory assert.ok(r.success, `phase remove failed: ${r.error}`); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.strictEqual(after, before, 'issue control: removal without a directory must not touch STATE.md'); }); }); describe('bug #3572 controls and clamps', () => { test('#3572 control: phase insert alone leaves STATE.md untouched (issue control #2)', (t) => { const tmpDir = createTempProject('gsd-3572-ctl-'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: A\n**Goal:** x\n\n### Phase 2: B\n**Goal:** y\n', ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '---\ngsd_state_version: 1.0\nprogress:\n total_phases: 2\n---\n\n# Project State\n\nBody.\n', ); const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); const r = runGsdTools('phase insert 1 "Probe"', tmpDir); assert.ok(r.success, `phase insert failed: ${r.error}`); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.strictEqual(after, before, 'issue control: insert alone must not touch STATE.md'); t.after(() => cleanup(tmpDir)); }); test('#3572 clamp: a stale Total Phases: 0 never decrements to -1 on the next removal', (t) => { const tmpDir = createTempProject('gsd-3572-clamp-'); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: A\n**Goal:** x\n\n### Phase 2: B\n**Goal:** y\n', ); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '---\ngsd_state_version: 1.0\nprogress:\n total_phases: 2\n---\n\n# Project State\n\nTotal Phases: 0\n', ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); const r = runGsdTools('phase remove 2', tmpDir); assert.ok(r.success, `phase remove failed: ${r.error}`); const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.doesNotMatch(after, /Total Phases:\s*-\d+/, 'count must never go negative'); assert.match(after, /^Total Phases:\s*0$/m, 'stale zero stays clamped at 0'); t.after(() => cleanup(tmpDir)); }); });