/**
* Unit tests for phase lifecycle handlers.
*
* Tests phaseAdd, phaseAddBatch, phaseInsert, phaseScaffold, replaceInCurrentMilestone,
* and readModifyWriteRoadmapMd.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, readFile, rm, mkdir, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { existsSync } from 'node:fs';
// ─── Fixtures ─────────────────────────────────────────────────────────────
const MINIMAL_ROADMAP = `# Roadmap
## Current Milestone: v3.0 SDK-First Migration
### Phase 9: Foundation
**Goal:** Build foundation
**Requirements**: TBD
**Depends on:** Phase 8
**Plans:** 3 plans
Plans:
- [x] 09-01 (Foundation setup)
### Phase 10: Read-Only Queries
**Goal:** Port queries.
**Requirements**: TBD
**Depends on:** Phase 9
**Plans:** 3 plans
Plans:
- [x] 10-01 (Query setup)
---
*Last updated: 2026-04-08*
`;
const ROADMAP_WITH_DETAILS = `# Roadmap
v1.0 (shipped)
### Phase 1: Old Phase
**Goal:** Shipped already
**Plans:** 2 plans
## Current Milestone: v3.0 SDK-First Migration
### Phase 9: Foundation
**Goal:** Build foundation
**Requirements**: TBD
**Plans:** 3 plans
### Phase 10: Read-Only Queries
**Goal:** Port queries.
**Requirements**: TBD
**Plans:** 3 plans
---
*Last updated: 2026-04-08*
`;
const MINIMAL_STATE = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
---
# Project State
## Current Position
Phase: 10 (Read-Only Queries) — EXECUTING
Plan: 2 of 3
Status: Executing Phase 10
## Session Continuity
Last session: 2026-04-07T10:00:00.000Z
Stopped at: Completed 10-02-PLAN.md
`;
/** Create a test project with .planning structure. */
async function setupTestProject(
tmpDir: string,
opts?: { roadmap?: string; state?: string; config?: Record; phases?: string[] }
): Promise {
const planningDir = join(tmpDir, '.planning');
await mkdir(planningDir, { recursive: true });
const phasesDir = join(planningDir, 'phases');
await mkdir(phasesDir, { recursive: true });
await writeFile(join(planningDir, 'ROADMAP.md'), opts?.roadmap || MINIMAL_ROADMAP, 'utf-8');
await writeFile(join(planningDir, 'STATE.md'), opts?.state || MINIMAL_STATE, 'utf-8');
await writeFile(
join(planningDir, 'config.json'),
JSON.stringify(opts?.config || { model_profile: 'balanced', phase_naming: 'sequential' }),
'utf-8'
);
// Create phase directories if requested
if (opts?.phases) {
for (const phase of opts.phases) {
await mkdir(join(phasesDir, phase), { recursive: true });
await writeFile(join(phasesDir, phase, '.gitkeep'), '', 'utf-8');
}
}
return tmpDir;
}
// ─── Tests ────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-lifecycle-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ─── replaceInCurrentMilestone ──────────────────────────────────────────
describe('replaceInCurrentMilestone', () => {
it('replaces in full content when no details blocks', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
const content = '### Phase 9: Foundation\n**Plans:** 3 plans\n';
const result = replaceInCurrentMilestone(content, /3 plans/, '4 plans');
expect(result).toContain('4 plans');
});
it('only replaces after last block', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
const content = '\n### Phase 1: Old\n**Plans:** 3 plans\n \n\n### Phase 9: Current\n**Plans:** 3 plans\n';
const result = replaceInCurrentMilestone(content, /3 plans/, '4 plans');
// Should only replace in the current milestone section (after )
const before = result.slice(0, result.indexOf('') + ''.length);
const after = result.slice(result.indexOf('') + ''.length);
expect(before).toContain('3 plans'); // old milestone untouched
expect(after).toContain('4 plans'); // current milestone updated
});
it('replaces only in current milestone when older milestones are wrapped in ', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
const content = [
'# Roadmap',
'',
'',
'✅ v1.18 (shipped)
',
'',
'### Phase 1: Old Phase',
'',
'- [ ] Phase 1: Old Phase',
'',
' ',
'',
'',
'✅ v1.19 (shipped)
',
'',
'### Phase 2: Another Old Phase',
'',
' ',
'',
'## Current Milestone: v1.20',
'',
'- [ ] Phase 3: Current work',
'',
'### Phase 3: Current work',
'',
'**Plans:** 0/2 plans',
'',
].join('\n');
const pattern = /\*\*Plans:\*\* [^\n]+/;
const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete');
// Should update Phase 3's Plans line (current milestone)
expect(result).toContain('**Plans:** 2/2 plans complete');
// Should NOT touch v1.18 or v1.19 sections
expect(result).toContain('✅ v1.18');
expect(result).toContain('✅ v1.19');
});
it('replaces inside active milestone when it is wrapped in a block', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
// Scenario: active milestone is collapsed in (e.g. user collapsed it)
const content = [
'# Roadmap',
'',
'',
'✅ v1.18 (shipped)
',
'',
'### Phase 1: Old Phase',
'',
'**Plans:** 1/1 plans',
'',
' ',
'',
'',
'🚧 v1.19 in-progress
',
'',
'### Phase 2: Current Work',
'',
'**Plans:** 1/2 plans',
'',
' ',
'',
].join('\n');
const pattern = /\*\*Plans:\*\* [^\n]+/g;
const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete');
// The replacement should happen somewhere in the content (not silently dropped)
expect(result).toContain('**Plans:** 2/2 plans complete');
// v1.18 old plans line should remain untouched
expect(result).toContain('**Plans:** 1/1 plans');
});
it('replaces inside active even when footer text exists after ', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
// Scenario: active milestone is the last block, but a footer
// (e.g. "---\n*Last updated*") follows it. The fast-path sees after.trim()
// non-empty and replaces in the footer instead of inside the active block.
const content = [
'# Roadmap',
'',
'',
'v1.0 (Archived)
',
'',
'**Plans:** 1/1 plans',
'',
' ',
'',
'',
'v2.0 (Active)
',
'',
'**Plans:** 1/2 plans',
'',
' ',
'',
'---',
'*Last updated: 2026-01-01*',
].join('\n');
const pattern = /\*\*Plans:\*\* [^\n]+/g;
const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete');
// Active milestone inside last should be updated
expect(result).toContain('**Plans:** 2/2 plans complete');
// Archived milestone should remain untouched
expect(result).toContain('**Plans:** 1/1 plans');
// Footer should be preserved verbatim
expect(result).toContain('---');
expect(result).toContain('*Last updated: 2026-01-01*');
});
});
// ─── readModifyWriteRoadmapMd ───────────────────────────────────────────
describe('readModifyWriteRoadmapMd', () => {
it('reads, modifies, and writes ROADMAP.md atomically', async () => {
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
const result = await readModifyWriteRoadmapMd(tmpDir, (content) => {
return content.replace('Port queries.', 'Port all queries.');
});
expect(result).toContain('Port all queries.');
const ondisk = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(ondisk).toContain('Port all queries.');
});
it('creates and releases lockfile', async () => {
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await readModifyWriteRoadmapMd(tmpDir, (c) => c);
// Lock should be released after operation
const lockPath = join(tmpDir, '.planning', 'ROADMAP.md.lock');
expect(existsSync(lockPath)).toBe(false);
});
});
// ─── phaseAdd ──────────────────────────────────────────────────────────
describe('phaseAdd', () => {
it('creates directory and updates ROADMAP.md for sequential phase', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const result = await phaseAdd(['New Feature'], tmpDir);
const data = result.data as Record;
expect(data.phase_number).toBe(11);
expect(data.padded).toBe('11');
expect(data.name).toBe('New Feature');
expect(data.slug).toBe('new-feature');
expect(data.naming_mode).toBe('sequential');
// Verify directory was created
const dir = data.directory as string;
expect(dir).toContain('11-new-feature');
const phasesDir = join(tmpDir, '.planning', 'phases');
const entries = await readdir(phasesDir, { withFileTypes: true });
const newDir = entries.find(e => e.isDirectory() && e.name.includes('11-new-feature'));
expect(newDir).toBeTruthy();
// Verify .gitkeep
expect(existsSync(join(phasesDir, newDir!.name, '.gitkeep'))).toBe(true);
// Verify ROADMAP.md updated
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmap).toContain('### Phase 11: New Feature');
expect(roadmap).toContain('**Goal:** [To be planned]');
});
it('skips exactly phase 999 (backlog sentinel) when calculating next number', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmapWith999 = MINIMAL_ROADMAP.replace(
'---\n*Last updated',
'### Phase 999: Backlog\n\n**Goal:** Backlog items\n**Plans:** 0 plans\n\n---\n*Last updated'
);
await setupTestProject(tmpDir, { roadmap: roadmapWith999 });
const result = await phaseAdd(['After Ten'], tmpDir);
const data = result.data as Record;
// Should be 11, not 1000
expect(data.phase_number).toBe(11);
});
it('returns correct next phase id for projects using 1000+ canonical phase numbers (regression #3774)', async () => {
// Bug: scanSequentialMaxPhaseFromMilestone and scanSequentialMaxPhaseFromDirs
// used `num >= 999` instead of `num === 999`, causing every phase ≥ 1000 to be
// excluded from the max-scan. computeNextSequentialPhaseId returned 1 (0+1)
// instead of 1501 for a project whose highest phase is 1500.
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmapWith1000Plus = [
'# Roadmap',
'',
'## Current Milestone: v10.0 Large Project',
'',
'### Phase 1000: Foundation',
'',
'**Goal:** Foundation',
'**Requirements**: TBD',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 1000-01 (Foundation setup)',
'',
'### Phase 1500: Latest',
'',
'**Goal:** Latest',
'**Requirements**: TBD',
'**Depends on:** Phase 1499',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 1500-01 (Latest step)',
'',
'---',
'*Last updated: 2026-05-20*',
'',
].join('\n');
const phases = [
'1000-foundation',
'1100-alpha',
'1200-beta',
'1300-gamma',
'1400-delta',
'1500-latest',
];
await setupTestProject(tmpDir, { roadmap: roadmapWith1000Plus, phases });
const result = await phaseAdd(['Next After 1500'], tmpDir);
const data = result.data as Record;
// Must be 1501, not 1 (the pre-fix bug value)
expect(data.phase_number).toBe(1501);
});
it('distinguishes === 999 guard from === 1000 off-by-one: [999, 1000] fixture must yield 1001 (regression #3774)', async () => {
// Distinguishing fixture: phases [999, 1000] on disk + in ROADMAP.
// With correct guard (=== 999): skips 999, keeps 1000 as max → next = 1001 ✓
// With off-by-one guard (=== 1000): skips 1000, keeps 999 → max = 999 but
// 999 is itself skipped by the equality guard (999 === 999 is true when
// the guard fires), so actually keeps nothing → max = 0 → next = 1 ✗.
// Either way, the result diverges from 1001, catching the regression.
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmapWith999and1000 = [
'# 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');
const phases = [
'999-backlog',
'1000-first-four-digit',
];
await setupTestProject(tmpDir, { roadmap: roadmapWith999and1000, phases });
const result = await phaseAdd(['After One Thousand'], tmpDir);
const data = result.data as Record;
// Must be 1001: skips 999 (backlog sentinel), keeps 1000 as max, adds 1
expect(data.phase_number).toBe(1001);
});
it('throws GSDError with Validation for empty description', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseAdd([], tmpDir)).rejects.toThrow('description required');
});
it('inserts phase entry before last --- separator', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await phaseAdd(['Inserted Phase'], tmpDir);
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// The new phase should appear before the trailing ---
const phaseIdx = roadmap.indexOf('### Phase 11: Inserted Phase');
const sepIdx = roadmap.lastIndexOf('\n---');
expect(phaseIdx).toBeLessThan(sepIdx);
expect(phaseIdx).toBeGreaterThan(0);
});
it('detects max phase from bullet checklist format (regression #2726)', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmap = [
'# Roadmap',
'',
'## Current Milestone: v5.0',
'',
'- [x] Phase 76: Data Import',
'- [x] Phase 77: Data Transform',
'- [ ] Phase 88: Final Cleanup',
'',
].join('\n');
await setupTestProject(tmpDir, {
roadmap,
state: MINIMAL_STATE,
phases: [],
});
const result = await phaseAdd(['new-feature'], tmpDir);
const data = result.data as Record;
expect(data.phase_number).toBe(89);
expect(data.padded).toBe('89');
});
it('detects max phase from bold inline format (regression #2726)', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmap = [
'# Roadmap',
'',
'## Current Milestone: v5.0',
'',
'**Phase 50: Core Infrastructure**',
'**Phase 51: API Layer**',
'',
].join('\n');
await setupTestProject(tmpDir, {
roadmap,
state: MINIMAL_STATE,
phases: [],
});
const result = await phaseAdd(['new-feature'], tmpDir);
const data = result.data as Record;
expect(data.phase_number).toBe(52);
});
it('falls back to filesystem scan when no phase matches in ROADMAP (regression #2726)', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
// ROADMAP with no recognizable phase entries
const roadmap = '# Roadmap\n\n## Current Milestone: v5.0\n\nSome content without phases\n';
await setupTestProject(tmpDir, {
roadmap,
state: MINIMAL_STATE,
phases: ['45-legacy-phase', '46-another-phase'],
});
const result = await phaseAdd(['new-feature'], tmpDir);
const data = result.data as Record;
// Should detect phases 45 and 46 on disk, so new phase = 47
expect(data.phase_number).toBe(47);
});
it('filesystem fallback handles project-code-prefixed phase directories (regression coderabbit)', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmap = '# Roadmap\n\n## Current Milestone: v5.0\n\nSome content\n';
await setupTestProject(tmpDir, {
roadmap,
state: MINIMAL_STATE,
phases: [],
});
// Create prefixed directories manually (project_code = "CK" scenario)
const phasesDir = join(tmpDir, '.planning', 'phases');
await mkdir(join(phasesDir, 'CK-45-legacy-phase'), { recursive: true });
await mkdir(join(phasesDir, 'CK-46-another-phase'), { recursive: true });
const result = await phaseAdd(['new-feature'], tmpDir);
const data = result.data as Record;
// Should detect CK-45 and CK-46, so new phase = 47
expect(data.phase_number).toBe(47);
});
// ── Symptom A: --dry-run flag (#3226) ─────────────────────────────────
it('--dry-run returns JSON result without creating any files or modifying ROADMAP', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const roadmapBefore = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const result = await phaseAdd(['Dry Run Phase', '--dry-run'], tmpDir);
const data = result.data as Record;
// Result must include the computed fields
expect(data.phase_number).toBe(11);
expect(data.padded).toBe('11');
expect(data.name).toBe('Dry Run Phase');
expect(data.slug).toBe('dry-run-phase');
expect(data.dry_run).toBe(true);
expect(typeof data.roadmap_entry).toBe('string');
expect((data.roadmap_entry as string)).toContain('### Phase 11: Dry Run Phase');
// ROADMAP.md must be unchanged
const roadmapAfter = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmapAfter).toBe(roadmapBefore);
// No new phase directory must have been created
const phasesDir = join(tmpDir, '.planning', 'phases');
const entries = await readdir(phasesDir, { withFileTypes: true });
const newDir = entries.find(e => e.isDirectory() && e.name.includes('11-dry-run-phase'));
expect(newDir).toBeUndefined();
});
it('--dry-run works when flag appears after customId position', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const roadmapBefore = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// description + --dry-run — no customId; flag must not be mistaken for customId
const result = await phaseAdd(['My Feature', '--dry-run'], tmpDir);
const data = result.data as Record;
expect(data.dry_run).toBe(true);
expect(data.phase_number).toBe(11);
// ROADMAP must still be untouched
const roadmapAfter = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmapAfter).toBe(roadmapBefore);
});
// ── Symptom C: unknown flag rejection (#3226) ──────────────────────────
it('rejects unknown --flags with a validation error naming the flag', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseAdd(['My Feature', '--bogus-flag'], tmpDir)).rejects.toThrow('--bogus-flag');
});
it('rejects any unknown --flag even when mixed with dry-run', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseAdd(['Desc', '--dry-run', '--unknown'], tmpDir)).rejects.toThrow('--unknown');
});
// ── Symptom B: ROADMAP heading scan counts ### Phase N: (#3226 verify) ─
it('scans ### Phase N: headings in ROADMAP when no on-disk dirs exist (B already fixed)', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmap = [
'# Roadmap',
'',
'## Current Milestone: v5.0',
'',
'### Phase 5: Foundation',
'',
'**Goal:** Build foundation',
'**Plans:** 0 plans',
'',
].join('\n');
await setupTestProject(tmpDir, {
roadmap,
state: MINIMAL_STATE,
phases: [], // no on-disk dirs — must rely on ROADMAP scan
});
const result = await phaseAdd(['Next Phase'], tmpDir);
const data = result.data as Record;
// Must detect Phase 5 from ### heading → next = 6, not 1
expect(data.phase_number).toBe(6);
});
// ── Concurrent phase.add: no duplicate IDs (CR finding) ────────────────
it('concurrent phase.add calls produce distinct sequential phase numbers', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
// Fire two phase.add calls simultaneously. If computation happens outside
// the lock both will observe maxPhase=10 and claim newPhaseId=11 — collision.
const [r1, r2] = await Promise.all([
phaseAdd(['Concurrent Alpha'], tmpDir),
phaseAdd(['Concurrent Beta'], tmpDir),
]);
const n1 = (r1.data as Record).phase_number as number;
const n2 = (r2.data as Record).phase_number as number;
// Both must succeed and produce DIFFERENT numbers
expect(n1).not.toBe(n2);
// The pair must be {11, 12} — no gaps, no duplicates
const sorted = [n1, n2].sort((a, b) => a - b);
expect(sorted).toEqual([11, 12]);
// ROADMAP.md must contain exactly one entry for each phase
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const phase11Count = (roadmap.match(/### Phase 11:/g) || []).length;
const phase12Count = (roadmap.match(/### Phase 12:/g) || []).length;
expect(phase11Count).toBe(1);
expect(phase12Count).toBe(1);
// Both phase directories must exist on disk
const phasesDir = join(tmpDir, '.planning', 'phases');
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const has11 = dirs.some(d => d.startsWith('11-'));
const has12 = dirs.some(d => d.startsWith('12-'));
expect(has11).toBe(true);
expect(has12).toBe(true);
});
});
// ─── phaseAddBatch ─────────────────────────────────────────────────────
describe('phaseAddBatch', () => {
it('adds multiple sequential phases in one pass', async () => {
const { phaseAddBatch } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const result = await phaseAddBatch(['Alpha', 'Beta'], tmpDir);
const data = result.data as { phases: Array>; count: number };
expect(data.count).toBe(2);
expect(data.phases[0].phase_number).toBe(11);
expect(data.phases[0].name).toBe('Alpha');
expect(data.phases[1].phase_number).toBe(12);
expect(data.phases[1].name).toBe('Beta');
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmap).toContain('### Phase 11: Alpha');
expect(roadmap).toContain('### Phase 12: Beta');
const phasesDir = join(tmpDir, '.planning', 'phases');
expect(existsSync(join(phasesDir, '11-alpha', '.gitkeep'))).toBe(true);
expect(existsSync(join(phasesDir, '12-beta', '.gitkeep'))).toBe(true);
});
it('accepts --descriptions JSON array', async () => {
const { phaseAddBatch } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, { phases: ['09-foundation', '10-read-only-queries'] });
const result = await phaseAddBatch(
['--descriptions', JSON.stringify(['One', 'Two'])],
tmpDir,
);
const data = result.data as { count: number };
expect(data.count).toBe(2);
});
it('throws when no descriptions', async () => {
const { phaseAddBatch } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseAddBatch([], tmpDir)).rejects.toThrow('descriptions array required');
});
});
// ─── phaseInsert ────────────────────────────────────────────────────────
describe('phaseInsert', () => {
it('creates decimal phase directory after target phase', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const result = await phaseInsert(['10', 'Urgent Fix'], tmpDir);
const data = result.data as Record;
expect(data.phase_number).toBe('10.1');
expect(data.after_phase).toBe('10');
expect(data.name).toBe('Urgent Fix');
expect(data.slug).toBe('urgent-fix');
// Verify directory created
const dir = data.directory as string;
expect(dir).toContain('10.1-urgent-fix');
const phasesDir = join(tmpDir, '.planning', 'phases');
const entries = await readdir(phasesDir, { withFileTypes: true });
const newDir = entries.find(e => e.isDirectory() && e.name.includes('10.1-urgent-fix'));
expect(newDir).toBeTruthy();
});
it('scans both directories and ROADMAP.md for existing decimals to avoid collisions', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries', '10.1-hotfix'],
});
const result = await phaseInsert(['10', 'Another Fix'], tmpDir);
const data = result.data as Record;
// Should be 10.2 since 10.1 already exists on disk
expect(data.phase_number).toBe('10.2');
});
it('inserts section in ROADMAP.md after target phase', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await phaseInsert(['10', 'Urgent Fix'], tmpDir);
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmap).toContain('### Phase 10.1: Urgent Fix (INSERTED)');
// Should appear after Phase 10 section
const phase10Idx = roadmap.indexOf('### Phase 10:');
const insertedIdx = roadmap.indexOf('### Phase 10.1:');
expect(insertedIdx).toBeGreaterThan(phase10Idx);
});
it('throws GSDError for missing target phase', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseInsert(['99', 'Missing'], tmpDir)).rejects.toThrow('Phase 99 not found');
});
it('throws GSDError with Validation for missing args', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseInsert([], tmpDir)).rejects.toThrow('after-phase and description required');
});
it('#3098 preserved: throws when bullet-only entry in a heading-style ROADMAP (hybrid = missing detail section)', async () => {
// A hybrid ROADMAP: phase 9 has a heading but phase 10 only has a bullet
// summary entry. Insert must still reject with "missing a detail section"
// because the surrounding ROADMAP uses heading-style phases.
const { phaseInsert } = await import('./phase-lifecycle.js');
const hybridRoadmap = `# Roadmap\n\n## Current Milestone\n\n### Phase 9: Foundation\n\n- [ ] **Phase 10: Queries**\n`;
await setupTestProject(tmpDir, {
roadmap: hybridRoadmap,
phases: ['09-foundation'],
});
await expect(phaseInsert(['10', 'Hotfix'], tmpDir)).rejects.toThrow('missing a detail section');
});
// ─── #3815: checked-bullet ROADMAP format ─────────────────────────────
const BULLET_ONLY_ROADMAP = `# Roadmap
## Current Milestone: v2.0 Agent Skills
- [x] **Phase 01: bootstrap** — completed 2026-04-01
- [ ] **Phase 02: core-loop**
- [ ] **Phase 03: persistence**
---
*Last updated: 2026-05-01*
`;
it('#3815: inserts decimal phase between bullets in a checked-bullet ROADMAP', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: BULLET_ONLY_ROADMAP,
phases: ['01-bootstrap', '02-core-loop', '03-persistence'],
});
const result = await phaseInsert(['02', 'hot patch'], tmpDir);
const data = result.data as Record;
expect(data.phase_number).toBe('02.1');
expect(data.after_phase).toBe('02');
expect(data.name).toBe('hot patch');
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// New bullet must be present
expect(roadmap).toMatch(/- \[ \].*Phase 02\.1:.*hot patch/i);
// New bullet must appear AFTER the Phase 02 bullet
const phase02Idx = roadmap.indexOf('Phase 02: core-loop');
const insertedIdx = roadmap.search(/Phase 02\.1:/i);
expect(phase02Idx).toBeGreaterThanOrEqual(0);
expect(insertedIdx).toBeGreaterThan(phase02Idx);
// New bullet must appear BEFORE the Phase 03 bullet (positional assertion)
const phase03Idx = roadmap.indexOf('Phase 03: persistence');
expect(insertedIdx).toBeLessThan(phase03Idx);
// Must NOT corrupt the surrounding bullet structure
expect(roadmap).toContain('- [x] **Phase 01: bootstrap**');
expect(roadmap).toContain('- [ ] **Phase 02: core-loop**');
expect(roadmap).toContain('- [ ] **Phase 03: persistence**');
});
it('#3815: inserts at end of bullet list when target is the last phase', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: BULLET_ONLY_ROADMAP,
phases: ['01-bootstrap', '02-core-loop', '03-persistence'],
});
const result = await phaseInsert(['03', 'final extra'], tmpDir);
const data = result.data as Record;
expect(data.phase_number).toBe('03.1');
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmap).toMatch(/- \[ \].*Phase 03\.1:.*final extra/i);
// Must appear after Phase 03 bullet
const phase03Idx = roadmap.indexOf('Phase 03: persistence');
const insertedIdx = roadmap.search(/Phase 03\.1:/i);
expect(insertedIdx).toBeGreaterThan(phase03Idx);
});
it('#3815: plain bullet format (no bold) also works', async () => {
const { phaseInsert } = await import('./phase-lifecycle.js');
const PLAIN_BULLET_ROADMAP = `# Roadmap
## Current Milestone: v2.0
- [ ] Phase 01: foo
- [ ] Phase 02: bar
- [ ] Phase 03: baz
---
*Last updated: 2026-05-01*
`;
await setupTestProject(tmpDir, {
roadmap: PLAIN_BULLET_ROADMAP,
phases: ['01-foo', '02-bar', '03-baz'],
});
const result = await phaseInsert(['02', 'inserted'], tmpDir);
const data = result.data as Record;
expect(data.phase_number).toBe('02.1');
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmap).toMatch(/- \[ \].*Phase 02\.1:.*inserted/i);
const phase02Idx = roadmap.indexOf('Phase 02: bar');
const insertedIdx = roadmap.search(/Phase 02\.1:/i);
const phase03Idx = roadmap.indexOf('Phase 03: baz');
expect(insertedIdx).toBeGreaterThan(phase02Idx);
expect(insertedIdx).toBeLessThan(phase03Idx);
});
});
// ─── phaseScaffold ──────────────────────────────────────────────────────
describe('phaseScaffold', () => {
it('creates context template for a phase', async () => {
const { phaseScaffold } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation'],
});
const result = await phaseScaffold(['context', '9'], tmpDir);
const data = result.data as Record;
expect(data.created).toBe(true);
const filePath = data.path as string;
expect(filePath).toContain('09-CONTEXT.md');
// Check content
const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-CONTEXT.md');
expect(existsSync(fullPath)).toBe(true);
const content = await readFile(fullPath, 'utf-8');
expect(content).toContain('phase: "09"');
expect(content).toContain('Context');
});
it('creates uat template', async () => {
const { phaseScaffold } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation'],
});
const result = await phaseScaffold(['uat', '9'], tmpDir);
const data = result.data as Record;
expect(data.created).toBe(true);
const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-UAT.md');
expect(existsSync(fullPath)).toBe(true);
const content = await readFile(fullPath, 'utf-8');
expect(content).toContain('User Acceptance Testing');
});
it('creates verification template', async () => {
const { phaseScaffold } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation'],
});
const result = await phaseScaffold(['verification', '9'], tmpDir);
const data = result.data as Record;
expect(data.created).toBe(true);
const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-VERIFICATION.md');
expect(existsSync(fullPath)).toBe(true);
const content = await readFile(fullPath, 'utf-8');
expect(content).toContain('Verification');
});
it('creates phase-dir under phases/', async () => {
const { phaseScaffold } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
const result = await phaseScaffold(['phase-dir', '15', 'New Module'], tmpDir);
const data = result.data as Record;
expect(data.created).toBe(true);
const dir = data.directory as string;
expect(dir).toContain('15-new-module');
});
it('returns already_exists for existing file', async () => {
const { phaseScaffold } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation'],
});
// Create first
await phaseScaffold(['context', '9'], tmpDir);
// Second call should return already_exists
const result = await phaseScaffold(['context', '9'], tmpDir);
const data = result.data as Record;
expect(data.created).toBe(false);
expect(data.reason).toBe('already_exists');
});
it('throws GSDError for unknown type', async () => {
const { phaseScaffold } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation'],
});
await expect(phaseScaffold(['badtype', '9'], tmpDir)).rejects.toThrow('Unknown scaffold type');
});
});
// ─── phaseRemove ─────────────────────────────────────────────────────────
const ROADMAP_FOR_REMOVE = `# Roadmap
## Current Milestone: v3.0 SDK-First Migration
### Phase 5: Auth
**Goal:** Build authentication
**Requirements**: TBD
**Depends on:** Phase 4
**Plans:** 2 plans
Plans:
- [x] 05-01 (Auth setup)
- [x] 05-02 (Auth complete)
### Phase 6: Dashboard
**Goal:** Build dashboard
**Requirements**: TBD
**Depends on:** Phase 5
**Plans:** 3 plans
Plans:
- [ ] 06-01 (Dashboard setup)
### Phase 7: API
**Goal:** Build API layer
**Requirements**: TBD
**Depends on:** Phase 6
**Plans:** 2 plans
Plans:
- [ ] 07-01 (API setup)
---
*Last updated: 2026-04-08*
`;
const STATE_FOR_REMOVE = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
progress:
total_phases: 7
completed_phases: 4
total_plans: 15
completed_plans: 12
percent: 80
---
# Project State
## Current Position
Phase: 6 (Dashboard) — EXECUTING
Plan: 1 of 3
Status: Executing Phase 6
## Session Continuity
Last session: 2026-04-08T10:00:00.000Z
Stopped at: Started
`;
describe('phaseRemove', () => {
it('removes integer phase directory and renumbers subsequent phases', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
const phasesDir = join(tmpDir, '.planning', 'phases');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '07-api'],
});
// Create files inside directories to verify file renaming
await writeFile(join(phasesDir, '06-dashboard', '06-01-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(phasesDir, '07-api', '07-01-PLAN.md'), 'plan', 'utf-8');
const result = await phaseRemove(['6'], tmpDir);
const data = result.data as Record;
expect(data.removed).toBe('6');
expect(data.directory_deleted).toBeTruthy();
expect(data.roadmap_updated).toBe(true);
expect(data.state_updated).toBe(true);
// Phase 6 dir should be gone
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
expect(dirNames.find(d => d.includes('06-dashboard'))).toBeUndefined();
// Phase 7 should have been renamed to 06
const renamedDir = dirNames.find(d => d.includes('06-api'));
expect(renamedDir).toBeTruthy();
// Files inside renamed dir should also be renamed
const files = await readdir(join(phasesDir, renamedDir!));
expect(files.some(f => f.includes('06-01'))).toBe(true);
expect(files.some(f => f.includes('07-01'))).toBe(false);
});
it('removes decimal phase and renumbers sibling decimals', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
const decimalRoadmap = ROADMAP_FOR_REMOVE.replace(
'### Phase 7: API',
'### Phase 6.1: Hotfix A\n\n**Goal:** Fix A\n**Plans:** 1 plans\n\n### Phase 6.2: Hotfix B\n\n**Goal:** Fix B\n**Plans:** 1 plans\n\n### Phase 6.3: Hotfix C\n\n**Goal:** Fix C\n**Plans:** 1 plans\n\n### Phase 7: API'
);
const phasesDir = join(tmpDir, '.planning', 'phases');
await setupTestProject(tmpDir, {
roadmap: decimalRoadmap,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '06.1-hotfix-a', '06.2-hotfix-b', '06.3-hotfix-c', '07-api'],
});
// Create files with phase ID in name
await writeFile(join(phasesDir, '06.2-hotfix-b', '06.2-01-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(phasesDir, '06.3-hotfix-c', '06.3-01-PLAN.md'), 'plan', 'utf-8');
const result = await phaseRemove(['6.1'], tmpDir);
const data = result.data as Record;
expect(data.removed).toBe('6.1');
// 06.1 should be gone
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
expect(dirNames.find(d => d.includes('06.1-hotfix-a'))).toBeUndefined();
// 06.2 should become 06.1, 06.3 should become 06.2
expect(dirNames.find(d => d.includes('06.1-hotfix-b'))).toBeTruthy();
expect(dirNames.find(d => d.includes('06.2-hotfix-c'))).toBeTruthy();
expect(dirNames.find(d => d.includes('06.3'))).toBeUndefined();
// Files inside renamed dirs should be renamed
const dir1Files = await readdir(join(phasesDir, '06.1-hotfix-b'));
expect(dir1Files.some(f => f.includes('06.1-01'))).toBe(true);
const dir2Files = await readdir(join(phasesDir, '06.2-hotfix-c'));
expect(dir2Files.some(f => f.includes('06.2-01'))).toBe(true);
});
it('requires --force to remove phase with SUMMARY files', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
const phasesDir = join(tmpDir, '.planning', 'phases');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '07-api'],
});
// Create a SUMMARY file to simulate executed work
await writeFile(join(phasesDir, '06-dashboard', '06-01-SUMMARY.md'), 'summary', 'utf-8');
await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('--force');
});
it('allows removal with --force even when SUMMARY files exist', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
const phasesDir = join(tmpDir, '.planning', 'phases');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '07-api'],
});
await writeFile(join(phasesDir, '06-dashboard', '06-01-SUMMARY.md'), 'summary', 'utf-8');
const result = await phaseRemove(['6', '--force'], tmpDir);
const data = result.data as Record;
expect(data.removed).toBe('6');
expect(data.directory_deleted).toBeTruthy();
});
it('bug-3409: accepts --force before phase id', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
const phasesDir = join(tmpDir, '.planning', 'phases');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '07-api'],
});
await writeFile(join(phasesDir, '06-dashboard', '06-01-SUMMARY.md'), 'summary', 'utf-8');
const result = await phaseRemove(['--force', '6'], tmpDir);
const data = result.data as Record;
expect(data.removed).toBe('6');
expect(data.directory_deleted).toBeTruthy();
});
it('throws GSDError when ROADMAP.md is missing', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
// Set up without ROADMAP.md
const planningDir = join(tmpDir, '.planning');
await mkdir(planningDir, { recursive: true });
const phasesDir = join(planningDir, 'phases');
await mkdir(phasesDir, { recursive: true });
await writeFile(join(planningDir, 'STATE.md'), STATE_FOR_REMOVE, 'utf-8');
await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('ROADMAP.md not found');
});
it('throws GSDError when phase number is missing', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
});
await expect(phaseRemove([], tmpDir)).rejects.toThrow('phase number required');
});
it('throws GSDError when target phase does not exist and does not mutate STATE.md', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '07-api'],
});
await expect(phaseRemove(['99'], tmpDir)).rejects.toThrow('Phase 99 not found');
const stateContent = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(stateContent).toMatch(/total_phases:\s*7/);
});
it('updates ROADMAP.md by removing phase section and renumbering', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '07-api'],
});
await phaseRemove(['6'], tmpDir);
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Phase 6 section should be removed
expect(roadmap).not.toContain('### Phase 6: Dashboard');
// Phase 7 should be renumbered to 6
expect(roadmap).toContain('### Phase 6: API');
// Plan references should be renumbered
expect(roadmap).toContain('06-01');
expect(roadmap).not.toContain('07-01');
});
it('decrements total_phases in STATE.md frontmatter', async () => {
const { phaseRemove } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_REMOVE,
state: STATE_FOR_REMOVE,
phases: ['05-auth', '06-dashboard', '07-api'],
});
await phaseRemove(['6'], tmpDir);
const stateContent = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// total_phases should be decremented from 7 to 6
expect(stateContent).toMatch(/total_phases:\s*6/);
});
});
// ─── phaseComplete ─────────────────────────────────────────────────────────
const ROADMAP_FOR_COMPLETE = `# Roadmap
v1.0 (shipped)
### Phase 1: Old Phase
**Goal:** Shipped already
**Plans:** 2 plans
## Current Milestone: v3.0 SDK-First Migration
| Phase | Plans | Status | Completed |
|-------|-------|--------|-----------|
| 9. | 3/3 | Complete | 2026-04-01 |
| 10. | 0/3 | In Progress | |
| 11. | 0/2 | Not Started | |
- [x] Phase 9: Foundation (completed 2026-04-01)
- [ ] Phase 10: Read-Only Queries
- [ ] Phase 11: Final Phase
### Phase 9: Foundation
**Goal:** Build foundation
**Requirements**: FOUND-01, FOUND-02
**Depends on:** Phase 8
**Plans:** 3/3 plans complete
Plans:
- [x] 09-01 (Foundation setup)
- [x] 09-02 (Foundation core)
- [x] 09-03 (Foundation tests)
### Phase 10: Read-Only Queries
**Goal:** Port queries
**Requirements**: QUERY-01
**Depends on:** Phase 9
**Plans:** 3 plans
Plans:
- [x] 10-01 (Query setup)
- [x] 10-02 (Query core)
- [ ] 10-03 (Query tests)
### Phase 11: Final Phase
**Goal:** Final work
**Requirements**: FINAL-01
**Depends on:** Phase 10
**Plans:** 2 plans
Plans:
- [ ] 11-01 (Final setup)
- [ ] 11-02 (Final complete)
---
*Last updated: 2026-04-08*
`;
const STATE_FOR_COMPLETE = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
progress:
total_phases: 3
completed_phases: 1
total_plans: 8
completed_plans: 5
percent: 33
---
# Project State
## Current Position
Phase: 10 of 3 (Read-Only Queries) — EXECUTING
Plan: 3 of 3
Status: Executing Phase 10
Last activity: 2026-04-08
## Performance Metrics
**Velocity:**
- Total plans completed: 3
- Average duration: --
- Total execution time: 0 hours
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| 9 | 3 | - | - |
## Session Continuity
Last session: 2026-04-08T10:00:00.000Z
Stopped at: Completed 10-03-PLAN.md
`;
const REQUIREMENTS_FOR_COMPLETE = `# Requirements
## Checklist
- [x] **FOUND-01** Foundation setup
- [x] **FOUND-02** Foundation core
- [ ] **QUERY-01** Query implementation
- [ ] **FINAL-01** Final work
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| FOUND-01 | Phase 9 | Complete |
| FOUND-02 | Phase 9 | Complete |
| QUERY-01 | Phase 10 | In Progress |
| FINAL-01 | Phase 11 | Pending |
`;
describe('phaseComplete', () => {
it('marks phase checkbox, updates progress table, and plan count in ROADMAP.md', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
});
// Create PLAN and SUMMARY files for phase 10
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan1', 'utf-8');
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan2', 'utf-8');
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan3', 'utf-8');
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary1', 'utf-8');
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary2', 'utf-8');
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary3', 'utf-8');
// Create REQUIREMENTS.md
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
const result = await phaseComplete(['10'], tmpDir);
const data = result.data as Record;
expect(data.completed_phase).toBe('10');
expect(data.plans_executed).toBe('3/3');
expect(data.is_last_phase).toBe(false);
expect(data.next_phase).toBeTruthy();
expect(data.roadmap_updated).toBe(true);
// Check ROADMAP.md updates
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Checkbox should be marked
expect(roadmap).toMatch(/\[x\].*Phase 10/);
// Progress table should show Complete
expect(roadmap).toMatch(/10\.?\s*\|.*3\/3.*\|.*Complete/i);
// Plan count in section should be updated
expect(roadmap).toContain('3/3 plans complete');
// Plan checkboxes should be [x]
expect(roadmap).toMatch(/\[x\] 10-01/);
expect(roadmap).toMatch(/\[x\] 10-02/);
expect(roadmap).toMatch(/\[x\] 10-03/);
});
it('updates REQUIREMENTS.md checkboxes and traceability table', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
});
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan1', 'utf-8');
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan2', 'utf-8');
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan3', 'utf-8');
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary1', 'utf-8');
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary2', 'utf-8');
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary3', 'utf-8');
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
await phaseComplete(['10'], tmpDir);
const req = await readFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
// QUERY-01 checkbox should be marked
expect(req).toMatch(/\[x\].*\*\*QUERY-01\*\*/);
// Traceability should show Complete for QUERY-01
expect(req).toMatch(/QUERY-01\s*\|.*\|\s*Complete\s*\|/);
// FINAL-01 should remain Pending
expect(req).toMatch(/FINAL-01\s*\|.*\|\s*Pending\s*\|/);
});
it('updates STATE.md fields: current phase, status, completed phases, percent', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
});
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
await phaseComplete(['10'], tmpDir);
const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// Phase should advance to 11
expect(state).toMatch(/Phase:\s*11/);
// Status should indicate ready to plan
expect(state).toMatch(/Status:\s*Ready to plan/);
// Completed phases should be incremented from 1 to 2
expect(state).toMatch(/completed_phases:\s*2/);
// Percent should be recalculated (2/3 = 67%)
expect(state).toMatch(/percent:\s*67/);
});
it('detects next phase from filesystem, falls back to ROADMAP.md', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
});
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
const result = await phaseComplete(['10'], tmpDir);
const data = result.data as Record;
// Next phase should be 11 (from filesystem)
expect(data.next_phase).toBe('11');
expect(data.is_last_phase).toBe(false);
});
it('sets is_last_phase when completing the final phase', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
});
const p11Dir = join(tmpDir, '.planning', 'phases', '11-final-phase');
await writeFile(join(p11Dir, '11-01-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p11Dir, '11-01-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
const result = await phaseComplete(['11'], tmpDir);
const data = result.data as Record;
expect(data.is_last_phase).toBe(true);
expect(data.next_phase).toBeNull();
// State should show milestone complete
const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(state).toMatch(/Status:\s*Milestone complete/);
});
it('collects UAT/VERIFICATION warnings without blocking', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
});
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
// Create UAT file with pending status
await writeFile(join(p10Dir, '10-UAT.md'), '---\nresult: pending\n---\nPending tests', 'utf-8');
// Create VERIFICATION file with gaps
await writeFile(join(p10Dir, '10-VERIFICATION.md'), '---\nstatus: gaps_found\n---\nGaps', 'utf-8');
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
const result = await phaseComplete(['10'], tmpDir);
const data = result.data as Record;
// Should complete despite warnings
expect(data.completed_phase).toBe('10');
expect(data.has_warnings).toBe(true);
const warnings = data.warnings as string[];
expect(warnings.length).toBeGreaterThan(0);
expect(warnings.some(w => w.includes('pending'))).toBe(true);
expect(warnings.some(w => w.includes('gaps'))).toBe(true);
});
it('throws GSDError for missing phase', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation'],
});
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
await expect(phaseComplete(['99'], tmpDir)).rejects.toThrow('Phase 99 not found');
});
it('updates performance metrics table in STATE.md', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
roadmap: ROADMAP_FOR_COMPLETE,
state: STATE_FOR_COMPLETE,
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
});
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan', 'utf-8');
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary', 'utf-8');
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
await phaseComplete(['10'], tmpDir);
const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// Total plans completed should be incremented: 3 + 3 = 6
expect(state).toContain('Total plans completed: 6');
// By Phase table should have a row for phase 10
expect(state).toMatch(/\|\s*10\s*\|\s*3\s*\|/);
});
it('does not overwrite plan checkbox when **Plans:** is on its own line (regression #2728)', async () => {
const { phaseComplete } = await import('./phase-lifecycle.js');
const roadmap = [
'# Roadmap',
'',
'## Current Milestone: v3.0',
'',
'- [ ] Phase 7: marketing-landing-v2',
'',
'### Phase 7: marketing-landing-v2',
'',
'**Goal:** Landing page',
'**Plans:**',
'- [x] 07-01-cherry-pick-foundation-PLAN.md — Wave 1',
'- [x] 07-02-routing-auth-seo-PLAN.md — Wave 2',
'',
'### Phase 8: p3-nice-to-haves',
'',
'**Goal:** Nice to haves',
'**Plans:** 3 plans',
'',
].join('\n');
const state = [
'---',
'gsd_state_version: 1.0',
'milestone: v3.0',
'status: executing',
'progress:',
' total_phases: 2',
' completed_phases: 0',
' total_plans: 4',
' completed_plans: 2',
' percent: 50',
'---',
'',
'# Project State',
'',
'Phase: 7 of 2 — EXECUTING',
'Status: Executing Phase 7',
].join('\n');
await setupTestProject(tmpDir, {
roadmap,
state,
phases: ['07-marketing-landing-v2', '08-p3-nice-to-haves'],
});
const p7Dir = join(tmpDir, '.planning', 'phases', '07-marketing-landing-v2');
await writeFile(join(p7Dir, '07-01-PLAN.md'), 'plan1', 'utf-8');
await writeFile(join(p7Dir, '07-02-PLAN.md'), 'plan2', 'utf-8');
await writeFile(join(p7Dir, '07-01-SUMMARY.md'), 'summary1', 'utf-8');
await writeFile(join(p7Dir, '07-02-SUMMARY.md'), 'summary2', 'utf-8');
await phaseComplete(['7'], tmpDir);
const updated = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// The plan lines must NOT be replaced with "N/N plans complete"
expect(updated).toContain('07-01-cherry-pick-foundation-PLAN.md');
expect(updated).toContain('07-02-routing-auth-seo-PLAN.md');
expect(updated).not.toMatch(/^2\/2 plans complete/m);
// Phase 8's **Plans:** line must NOT be touched
expect(updated).toContain('**Plans:** 3 plans');
});
});
// ─── phasesClear ────────────────────────────────────────────────────────────
describe('phasesClear', () => {
it('throws GSDError without --confirm flag, showing count', async () => {
const { phasesClear } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries', '999.1-backlog'],
});
// Should throw with count of dirs to delete (2, not 3 since 999.1 is excluded)
await expect(phasesClear([], tmpDir)).rejects.toThrow(/2 phase director/);
});
it('deletes all dirs except 999.x with --confirm', async () => {
const { phasesClear } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries', '999.1-backlog'],
});
const result = await phasesClear(['--confirm'], tmpDir);
const data = result.data as Record;
expect(data.cleared).toBe(2);
// Verify filesystem
const phasesDir = join(tmpDir, '.planning', 'phases');
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
expect(dirNames.length).toBe(1);
expect(dirNames[0]).toContain('999');
});
it('returns 0 cleared when phases dir is empty', async () => {
const { phasesClear } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, { phases: [] });
const result = await phasesClear(['--confirm'], tmpDir);
const data = result.data as Record;
expect(data.cleared).toBe(0);
});
});
// ─── phasesArchive ──────────────────────────────────────────────────────────
describe('phasesArchive', () => {
it('moves milestone phase dirs to milestones/{version}-phases/', async () => {
const { phasesArchive } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const result = await phasesArchive(['v3.0'], tmpDir);
const data = result.data as Record;
expect(data.version).toBe('v3.0');
expect((data.archived as number)).toBeGreaterThan(0);
// Verify archive directory exists
const archiveDir = join(tmpDir, '.planning', 'milestones', 'v3.0-phases');
expect(existsSync(archiveDir)).toBe(true);
// Verify dirs were moved
const archivedEntries = await readdir(archiveDir, { withFileTypes: true });
const archivedDirs = archivedEntries.filter(e => e.isDirectory()).map(e => e.name);
expect(archivedDirs.length).toBeGreaterThan(0);
// Original dirs should be gone
const phasesDir = join(tmpDir, '.planning', 'phases');
const remaining = await readdir(phasesDir, { withFileTypes: true });
const remainingDirs = remaining.filter(e => e.isDirectory()).map(e => e.name);
expect(remainingDirs.length).toBe(0);
});
});
// ─── milestoneComplete help-flag defense (#3259) ────────────────────────────
describe('milestoneComplete help-flag defense', () => {
it('rejects --help as a version value with GSDError before any disk write', async () => {
const { milestoneComplete } = await import('./phase-lifecycle.js');
const { GSDError, ErrorClassification } = await import('../errors.js');
await setupTestProject(tmpDir);
// Capture pre-invocation filesystem state
const planningDir = join(tmpDir, '.planning');
const milestonesPath = join(planningDir, 'MILESTONES.md');
const statePath = join(planningDir, 'STATE.md');
const preStateStat = await import('node:fs').then((m) => m.statSync(statePath));
const milestonesExistedBefore = existsSync(milestonesPath);
let thrown: unknown;
try {
await milestoneComplete(['--help'], tmpDir);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(GSDError);
const err = thrown as InstanceType;
expect(err.classification).toBe(ErrorClassification.Validation);
expect(err.message).toContain('--help');
// Assert no files were written
const postStateStat = await import('node:fs').then((m) => m.statSync(statePath));
expect(postStateStat.mtimeMs).toBe(preStateStat.mtimeMs);
expect(existsSync(milestonesPath)).toBe(milestonesExistedBefore);
});
it('rejects -h as a version value with GSDError before any disk write', async () => {
const { milestoneComplete } = await import('./phase-lifecycle.js');
const { GSDError, ErrorClassification } = await import('../errors.js');
await setupTestProject(tmpDir);
const statePath = join(tmpDir, '.planning', 'STATE.md');
const preStateStat = await import('node:fs').then((m) => m.statSync(statePath));
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
const milestonesExistedBefore = existsSync(milestonesPath);
let thrown: unknown;
try {
await milestoneComplete(['-h'], tmpDir);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(GSDError);
const err = thrown as InstanceType;
expect(err.classification).toBe(ErrorClassification.Validation);
expect(err.message).toContain('-h');
// Assert no files were written
const postStateStat = await import('node:fs').then((m) => m.statSync(statePath));
expect(postStateStat.mtimeMs).toBe(preStateStat.mtimeMs);
expect(existsSync(milestonesPath)).toBe(milestonesExistedBefore);
});
});
// ─── Registry integration ──────────────────────────────────────────────────
describe('lifecycle handlers in registry', () => {
// Registry assembly (invariant checks + large handler map) routinely takes
// 5–10 s on a cold ESM module cache. The second test below reuses the
// cached import and completes instantly, but the first import is the cold
// path. Raise the per-test timeout so the warm-up doesn't surface as a
// spurious "Test timed out" failure (pre-existing: STACK_TRACE_ERROR mask).
it('registers all 7 lifecycle handlers with dot notation', async () => {
const { createRegistry } = await import('./index.js');
const registry = createRegistry();
const commands = [
'phase.add', 'phase.insert', 'phase.remove', 'phase.complete',
'phase.scaffold', 'phases.clear', 'phases.archive',
];
for (const cmd of commands) {
const handler = registry.getHandler(cmd);
expect(handler, `${cmd} should be registered`).toBeDefined();
}
}, 30_000);
it('registers space-delimited aliases', async () => {
const { createRegistry } = await import('./index.js');
const registry = createRegistry();
const commands = [
'phase add', 'phase insert', 'phase remove', 'phase complete',
'phase scaffold', 'phases clear', 'phases archive',
];
for (const cmd of commands) {
const handler = registry.getHandler(cmd);
expect(handler, `${cmd} should be registered`).toBeDefined();
}
});
});
// ─── CR-3267 regression: error-propagation in listDirectories ─────────────
describe('listDirectories — CR-3267 finding 1: non-ENOENT errors propagate', () => {
it('propagates EACCES from readdir instead of returning []', async () => {
const { listDirectories } = await import('./phase-filesystem-adapter.js');
// Create a real directory then remove read permission
const dir = await mkdtemp(join(tmpdir(), 'gsd-fs-acl-'));
const inner = join(dir, 'phases');
await mkdir(inner);
try {
await import('node:fs/promises').then(m => m.chmod(inner, 0o000));
await expect(listDirectories(inner)).rejects.toThrow();
} finally {
// Restore so cleanup can delete
await import('node:fs/promises').then(m => m.chmod(inner, 0o755));
await rm(dir, { recursive: true, force: true });
}
});
it('returns [] for ENOENT (directory gone between existsSync and readdir)', async () => {
// existsSync passes, but the directory has been removed before readdir —
// the ENOENT branch must still return [].
const { listDirectories } = await import('./phase-filesystem-adapter.js');
// We can't easily race the real FS, but we can verify the function tolerates
// a path that truly does not exist (existsSync returns false → early []).
const nonExistent = join(tmpdir(), 'gsd-does-not-exist-' + Date.now());
const result = await listDirectories(nonExistent);
expect(result).toEqual([]);
});
});
// ─── CR-3267 regression: error-propagation in readModifyWriteRoadmapMd ─────
describe('readModifyWriteRoadmapMd — CR-3267 finding 4: non-ENOENT errors propagate', () => {
it('propagates EACCES on ROADMAP.md readFile instead of treating as empty', async () => {
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
const dir = await mkdtemp(join(tmpdir(), 'gsd-roadmap-acl-'));
const planningDir = join(dir, '.planning');
await mkdir(planningDir, { recursive: true });
const roadmapPath = join(planningDir, 'ROADMAP.md');
await writeFile(roadmapPath, '# Roadmap\n', 'utf-8');
try {
await import('node:fs/promises').then(m => m.chmod(roadmapPath, 0o000));
await expect(
readModifyWriteRoadmapMd(dir, (c) => c)
).rejects.toThrow();
} finally {
await import('node:fs/promises').then(m => m.chmod(roadmapPath, 0o644));
await rm(dir, { recursive: true, force: true });
}
});
it('starts with empty content when ROADMAP.md is absent (ENOENT)', async () => {
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
const dir = await mkdtemp(join(tmpdir(), 'gsd-roadmap-noent-'));
const planningDir = join(dir, '.planning');
await mkdir(planningDir, { recursive: true });
// No ROADMAP.md written — must default to '' and create it
try {
const result = await readModifyWriteRoadmapMd(dir, (c) => c + 'NEW');
expect(result).toBe('NEW');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
// ─── CR-3267 regression: buildPhaseRoadmapEntry — no "Phase 0" dependency ──
describe('buildPhaseRoadmapEntry — CR-3267 finding 2: first sequential phase has no predecessor', () => {
it('omits Depends on line when phaseId is 1', async () => {
const { buildPhaseRoadmapEntry } = await import('./phase-lifecycle-policy.js');
const entry = buildPhaseRoadmapEntry(1, 'Bootstrap', 'sequential');
expect(entry).not.toContain('Depends on');
expect(entry).not.toContain('Phase 0');
});
it('includes Depends on line when phaseId is 2', async () => {
const { buildPhaseRoadmapEntry } = await import('./phase-lifecycle-policy.js');
const entry = buildPhaseRoadmapEntry(2, 'Second Phase', 'sequential');
expect(entry).toContain('**Depends on:** Phase 1');
});
it('omits Depends on line for custom naming mode regardless of id', async () => {
const { buildPhaseRoadmapEntry } = await import('./phase-lifecycle-policy.js');
const entry = buildPhaseRoadmapEntry('ALPHA', 'Custom', 'custom');
expect(entry).not.toContain('Depends on');
});
});
// ─── CR-3267 regression: collectDecimalSuffixesFromDirNames prefix grammar ─
describe('collectDecimalSuffixesFromDirNames — CR-3267 finding 3: alphanumeric prefixes accepted', () => {
it('matches directories with long alphanumeric project-code prefix', async () => {
const { collectDecimalSuffixesFromDirNames } = await import('./phase-lifecycle-policy.js');
// Prefix "MYAPP01" is longer than 6 chars and contains digits — was rejected before fix
const dirs = ['MYAPP01-3.1-some-work', 'MYAPP01-3.2-other-work', 'unrelated-dir'];
const result = collectDecimalSuffixesFromDirNames('3', dirs);
expect(result.has(1)).toBe(true);
expect(result.has(2)).toBe(true);
});
it('still matches directories with short uppercase-only prefix', async () => {
const { collectDecimalSuffixesFromDirNames } = await import('./phase-lifecycle-policy.js');
const dirs = ['AB-5.1-task', 'AB-5.3-other'];
const result = collectDecimalSuffixesFromDirNames('5', dirs);
expect(result.has(1)).toBe(true);
expect(result.has(3)).toBe(true);
});
it('matches directories with no prefix', async () => {
const { collectDecimalSuffixesFromDirNames } = await import('./phase-lifecycle-policy.js');
const dirs = ['3.1-plain', '3.2-also-plain'];
const result = collectDecimalSuffixesFromDirNames('3', dirs);
expect(result.has(1)).toBe(true);
expect(result.has(2)).toBe(true);
});
});
// ─── #3 defect-1: milestone.complete header duplication ───────────────────
//
// Historical evidence: `## v1.8.0 Quick Mode (Shipped: 2026-01-19)`
// Canonical template: `## ${version}${nameOpt ? ` ${nameOpt}` : ''} (Shipped: ${today})`
// GFM § ATX headings: https://github.github.com/gfm/#atx-headings
describe('milestoneComplete (#3 defect-1): header version/name rendering', () => {
it('no --name: header is "## v1.0 (Shipped: )" — version appears exactly once', async () => {
const { milestoneComplete } = await import('./phase-lifecycle.js');
const today = new Date().toISOString().split('T')[0]!;
const roadmap = `# Roadmap\n\n## Current Milestone: v1.0\n\n### Phase 1: Foundation\n\n**Goal:** TBD\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01\n`;
const state = `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# Project State\n\n## Current Position\n\nPhase: 1\nStatus: Executing\n`;
await setupTestProject(tmpDir, { roadmap, state });
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
await writeFile(milestonesPath, '# Milestones\n\n', 'utf-8');
await mkdir(join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
await milestoneComplete(['v1.0'], tmpDir);
const content = await readFile(milestonesPath, 'utf-8');
// Must match exactly "## v1.0 (Shipped: )" — version must NOT be doubled
expect(content).toContain(`## v1.0 (Shipped: ${today})`);
expect(content).not.toMatch(/## v1\.0 v1\.0/);
});
it('with --name "Foundation Release": header is "## v1.0 Foundation Release (Shipped: )"', async () => {
const { milestoneComplete } = await import('./phase-lifecycle.js');
const today = new Date().toISOString().split('T')[0]!;
const roadmap = `# Roadmap\n\n## Current Milestone: v1.0\n\n### Phase 1: Foundation\n\n**Goal:** TBD\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01\n`;
const state = `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# Project State\n\n## Current Position\n\nPhase: 1\nStatus: Executing\n`;
await setupTestProject(tmpDir, { roadmap, state });
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
await writeFile(milestonesPath, '# Milestones\n\n', 'utf-8');
await mkdir(join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
await milestoneComplete(['v1.0', '--name', 'Foundation', 'Release'], tmpDir);
const content = await readFile(milestonesPath, 'utf-8');
// version + name appended: "## v1.0 Foundation Release (Shipped: ...)"
expect(content).toContain(`## v1.0 Foundation Release (Shipped: ${today})`);
expect(content).not.toMatch(/## v1\.0 v1\.0/);
});
});
// ─── #3 defect-2: bullet-declared phases leak phase 99 into milestone ─────
//
// ROADMAP declares phases via checkbox bullets ONLY (GFM task list items:
// https://github.github.com/gfm/#task-list-items-extension-).
// When there are no `### Phase N:` headings in the milestone section,
// getMilestonePhaseFilter falls back to passAll (milestonePhaseNums.size === 0),
// admitting unrelated phase dirs like 99-unrelated.
// The fix: extend the phase-extraction regex to also match checkbox-bullet
// declarations so passAll is not triggered spuriously.
describe('milestoneComplete (#3 defect-2): checkbox-bullet phase declarations filter correctly', () => {
it('collects only phases 1 and 2 from bullet-only declarations — never phase 99', async () => {
const { milestoneComplete } = await import('./phase-lifecycle.js');
// Roadmap declares phases via GFM task-list bullets ONLY — no ### Phase N:
// headings exist anywhere in the document. This is the exact shape that
// triggers passAll in the current code (milestonePhaseNums.size === 0).
const roadmap = `# Roadmap
## Current Milestone: v1.0 Foundation
- [ ] **Phase 1: Foundation**
- [ ] **Phase 2: API**
---
*Last updated: 2026-01-01*
`;
const state = `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# Project State\n\n## Current Position\n\nPhase: 1\nStatus: Executing\n`;
await setupTestProject(tmpDir, { roadmap, state });
const phasesDir = join(tmpDir, '.planning', 'phases');
await mkdir(join(phasesDir, '01-foundation'), { recursive: true });
await mkdir(join(phasesDir, '02-api'), { recursive: true });
await mkdir(join(phasesDir, '99-unrelated'), { recursive: true });
const summaryContent = `# Phase Summary\n\n**Shipped the feature.**\n`;
await writeFile(join(phasesDir, '01-foundation', 'SUMMARY.md'), summaryContent, 'utf-8');
await writeFile(join(phasesDir, '02-api', 'SUMMARY.md'), summaryContent, 'utf-8');
// Phase 99 has a summary too — should NOT appear in accomplishments
await writeFile(join(phasesDir, '99-unrelated', 'SUMMARY.md'), '# Phase Summary\n\n**Should NOT appear.**\n', 'utf-8');
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
await writeFile(milestonesPath, '# Milestones\n\n', 'utf-8');
const result = await milestoneComplete(['v1.0'], tmpDir);
const data = result.data as Record;
const accomplishments = data.accomplishments as string[];
// phases 1 and 2 should be counted, 99 must be excluded
expect(data.phases).toBe(2);
expect(accomplishments.some(a => a.includes('Should NOT appear'))).toBe(false);
});
});
// ─── #3 defect-3: extractOneLinerFromBody section-scope leak ──────────────
//
// extractOneLinerFromBody must be bounded to the content between the first
// heading and the next heading of ANY level (GFM § ATX headings:
// https://github.github.com/gfm/#atx-headings).
// A bold match inside a subsequent subsection (e.g. "### Deviation 1 — bar")
// must NOT be returned.
describe('extractOneLinerFromBody (#3 defect-3): bounded to first heading section', () => {
it('returns the real one-liner and NOT the bold inside a following sub-heading', async () => {
const { extractOneLinerFromBody } = await import('./phase-lifecycle-policy.js');
const body = [
'# Phase Summary',
'',
'> This is a blockquote intro — not a one-liner.',
'',
'**Real one-liner: shipped the foo system.**',
'',
'Some prose.',
'',
'### Deviation 1 — bar',
'',
'**This bold should NOT be picked up.**',
'',
].join('\n');
const result = extractOneLinerFromBody(body);
expect(result).toBe('Real one-liner: shipped the foo system.');
expect(result).not.toContain('should NOT be picked up');
});
});