diff --git a/get-shit-done/bin/gsd-tools.test.cjs b/get-shit-done/bin/gsd-tools.test.cjs deleted file mode 100644 index 2136e0369..000000000 --- a/get-shit-done/bin/gsd-tools.test.cjs +++ /dev/null @@ -1,2302 +0,0 @@ -/** - * GSD Tools Tests - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const TOOLS_PATH = path.join(__dirname, 'gsd-tools.cjs'); - -// Helper to run gsd-tools command -function runGsdTools(args, cwd = process.cwd()) { - try { - const result = execSync(`node "${TOOLS_PATH}" ${args}`, { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - return { success: true, output: result.trim() }; - } catch (err) { - return { - success: false, - output: err.stdout?.toString().trim() || '', - error: err.stderr?.toString().trim() || err.message, - }; - } -} - -// Create temp directory structure -function createTempProject() { - const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); - return tmpDir; -} - -function cleanup(tmpDir) { - fs.rmSync(tmpDir, { recursive: true, force: true }); -} - -describe('history-digest command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('empty phases directory returns valid schema', () => { - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - - assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object'); - assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array'); - assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array'); - }); - - test('nested frontmatter fields extracted correctly', () => { - // Create phase directory with SUMMARY containing nested frontmatter - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - const summaryContent = `--- -phase: "01" -name: "Foundation Setup" -dependency-graph: - provides: - - "Database schema" - - "Auth system" - affects: - - "API layer" -tech-stack: - added: - - "prisma" - - "jose" -patterns-established: - - "Repository pattern" - - "JWT auth flow" -key-decisions: - - "Use Prisma over Drizzle" - - "JWT in httpOnly cookies" ---- - -# Summary content here -`; - - fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - - // Check nested dependency-graph.provides - assert.ok(digest.phases['01'], 'Phase 01 should exist'); - assert.deepStrictEqual( - digest.phases['01'].provides.sort(), - ['Auth system', 'Database schema'], - 'provides should contain nested values' - ); - - // Check nested dependency-graph.affects - assert.deepStrictEqual( - digest.phases['01'].affects, - ['API layer'], - 'affects should contain nested values' - ); - - // Check nested tech-stack.added - assert.deepStrictEqual( - digest.tech_stack.sort(), - ['jose', 'prisma'], - 'tech_stack should contain nested values' - ); - - // Check patterns-established (flat array) - assert.deepStrictEqual( - digest.phases['01'].patterns.sort(), - ['JWT auth flow', 'Repository pattern'], - 'patterns should be extracted' - ); - - // Check key-decisions - assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions'); - assert.ok( - digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'), - 'Should contain first decision' - ); - }); - - test('multiple phases merged into single digest', () => { - // Create phase 01 - const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.writeFileSync( - path.join(phase01Dir, '01-01-SUMMARY.md'), - `--- -phase: "01" -name: "Foundation" -provides: - - "Database" -patterns-established: - - "Pattern A" -key-decisions: - - "Decision 1" ---- -` - ); - - // Create phase 02 - const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api'); - fs.mkdirSync(phase02Dir, { recursive: true }); - fs.writeFileSync( - path.join(phase02Dir, '02-01-SUMMARY.md'), - `--- -phase: "02" -name: "API" -provides: - - "REST endpoints" -patterns-established: - - "Pattern B" -key-decisions: - - "Decision 2" -tech-stack: - added: - - "zod" ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - - // Both phases present - assert.ok(digest.phases['01'], 'Phase 01 should exist'); - assert.ok(digest.phases['02'], 'Phase 02 should exist'); - - // Decisions merged - assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total'); - - // Tech stack merged - assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod'); - }); - - test('malformed SUMMARY.md skipped gracefully', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - - // Valid summary - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -phase: "01" -provides: - - "Valid feature" ---- -` - ); - - // Malformed summary (no frontmatter) - fs.writeFileSync( - path.join(phaseDir, '01-02-SUMMARY.md'), - `# Just a heading -No frontmatter here -` - ); - - // Another malformed summary (broken YAML) - fs.writeFileSync( - path.join(phaseDir, '01-03-SUMMARY.md'), - `--- -broken: [unclosed ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`); - - const digest = JSON.parse(result.output); - assert.ok(digest.phases['01'], 'Phase 01 should exist'); - assert.ok( - digest.phases['01'].provides.includes('Valid feature'), - 'Valid feature should be extracted' - ); - }); - - test('flat provides field still works (backward compatibility)', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -phase: "01" -provides: - - "Direct provides" ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - assert.deepStrictEqual( - digest.phases['01'].provides, - ['Direct provides'], - 'Direct provides should work' - ); - }); - - test('inline array syntax supported', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -phase: "01" -provides: [Feature A, Feature B] -patterns-established: ["Pattern X", "Pattern Y"] ---- -` - ); - - const result = runGsdTools('history-digest', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const digest = JSON.parse(result.output); - assert.deepStrictEqual( - digest.phases['01'].provides.sort(), - ['Feature A', 'Feature B'], - 'Inline array should work' - ); - assert.deepStrictEqual( - digest.phases['01'].patterns.sort(), - ['Pattern X', 'Pattern Y'], - 'Inline quoted array should work' - ); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// phases list command -// ───────────────────────────────────────────────────────────────────────────── - -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 -// ───────────────────────────────────────────────────────────────────────────── - -describe('roadmap get-phase command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('extracts phase section from ROADMAP.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -## Phases - -### Phase 1: Foundation -**Goal:** Set up project infrastructure -**Plans:** 2 plans - -Some description here. - -### Phase 2: API -**Goal:** Build REST API -**Plans:** 3 plans -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, true, 'phase should be found'); - assert.strictEqual(output.phase_number, '1', 'phase number correct'); - assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); - assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); - }); - - test('returns not found for missing phase', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -### Phase 1: Foundation -**Goal:** Set up project -` - ); - - const result = runGsdTools('roadmap get-phase 5', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, false, 'phase should not be found'); - }); - - test('handles decimal phase numbers', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap - -### Phase 2: Main -**Goal:** Main work - -### Phase 2.1: Hotfix -**Goal:** Emergency fix -` - ); - - const result = runGsdTools('roadmap get-phase 2.1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, true, 'decimal phase should be found'); - assert.strictEqual(output.phase_name, 'Hotfix', 'phase name correct'); - assert.strictEqual(output.goal, 'Emergency fix', 'goal extracted'); - }); - - test('extracts full section content', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap - -### Phase 1: Setup -**Goal:** Initialize everything - -This phase covers: -- Database setup -- Auth configuration -- CI/CD pipeline - -### Phase 2: Build -**Goal:** Build features -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok(output.section.includes('Database setup'), 'section includes description'); - assert.ok(output.section.includes('CI/CD pipeline'), 'section includes all bullets'); - assert.ok(!output.section.includes('Phase 2'), 'section does not include next phase'); - }); - - test('handles missing ROADMAP.md gracefully', () => { - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, false, 'should return not found'); - assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why'); - }); - - test('accepts ## phase headers (two hashes)', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -## Phase 1: Foundation -**Goal:** Set up project infrastructure -**Plans:** 2 plans - -## Phase 2: API -**Goal:** Build REST API -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, true, 'phase with ## header should be found'); - assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); - assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); - }); - - test('detects malformed ROADMAP with summary list but no detail sections', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -## Phases - -- [ ] **Phase 1: Foundation** - Set up project -- [ ] **Phase 2: API** - Build REST API -` - ); - - const result = runGsdTools('roadmap get-phase 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.found, false, 'phase should not be found'); - assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap'); - assert.ok(output.message.includes('missing'), 'should explain the issue'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// phase next-decimal command -// ───────────────────────────────────────────────────────────────────────────── - -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'); - }); - - 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', () => { - 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 ---- - -## Task 1: Schema -` - ); - - fs.writeFileSync( - path.join(phaseDir, '03-02-PLAN.md'), - `--- -wave: 1 -autonomous: true -objective: Auth setup ---- - -## Task 1: JWT -` - ); - - fs.writeFileSync( - path.join(phaseDir, '03-03-PLAN.md'), - `--- -wave: 2 -autonomous: false -objective: API routes ---- - -## Task 1: Routes -` - ); - - 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'); - }); - - 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('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'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// state-snapshot command -// ───────────────────────────────────────────────────────────────────────────── - -describe('state-snapshot command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('missing STATE.md returns error', () => { - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file'); - }); - - test('extracts basic fields from STATE.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 -**Current Phase Name:** API Layer -**Total Phases:** 6 -**Current Plan:** 03-02 -**Total Plans in Phase:** 3 -**Status:** In progress -**Progress:** 45% -**Last Activity:** 2024-01-15 -**Last Activity Description:** Completed 03-01-PLAN.md -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.current_phase, '03', 'current phase extracted'); - assert.strictEqual(output.current_phase_name, 'API Layer', 'phase name extracted'); - assert.strictEqual(output.total_phases, 6, 'total phases extracted'); - assert.strictEqual(output.current_plan, '03-02', 'current plan extracted'); - assert.strictEqual(output.total_plans_in_phase, 3, 'total plans extracted'); - assert.strictEqual(output.status, 'In progress', 'status extracted'); - assert.strictEqual(output.progress_percent, 45, 'progress extracted'); - assert.strictEqual(output.last_activity, '2024-01-15', 'last activity date extracted'); - }); - - test('extracts decisions table', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 01 - -## Decisions Made - -| Phase | Decision | Rationale | -|-------|----------|-----------| -| 01 | Use Prisma | Better DX than raw SQL | -| 02 | JWT auth | Stateless authentication | -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.decisions.length, 2, 'should have 2 decisions'); - assert.strictEqual(output.decisions[0].phase, '01', 'first decision phase'); - assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'first decision summary'); - assert.strictEqual(output.decisions[0].rationale, 'Better DX than raw SQL', 'first decision rationale'); - }); - - test('extracts blockers list', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 - -## Blockers - -- Waiting for API credentials -- Need design review for dashboard -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.deepStrictEqual(output.blockers, [ - 'Waiting for API credentials', - 'Need design review for dashboard', - ], 'blockers extracted'); - }); - - test('extracts session continuity info', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 - -## Session - -**Last Date:** 2024-01-15 -**Stopped At:** Phase 3, Plan 2, Task 1 -**Resume File:** .planning/phases/03-api/03-02-PLAN.md -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.session.last_date, '2024-01-15', 'session date extracted'); - assert.strictEqual(output.session.stopped_at, 'Phase 3, Plan 2, Task 1', 'stopped at extracted'); - assert.strictEqual(output.session.resume_file, '.planning/phases/03-api/03-02-PLAN.md', 'resume file extracted'); - }); - - test('handles paused_at field', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# Project State - -**Current Phase:** 03 -**Paused At:** Phase 3, Plan 1, Task 2 - mid-implementation -` - ); - - const result = runGsdTools('state-snapshot', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.paused_at, 'Phase 3, Plan 1, Task 2 - mid-implementation', 'paused_at extracted'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// summary-extract command -// ───────────────────────────────────────────────────────────────────────────── - -describe('summary-extract command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('missing file returns error', () => { - const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.error, 'File not found', 'should report missing file'); - }); - - test('extracts all fields from SUMMARY.md', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -one-liner: Set up Prisma with User and Project models -key-files: - - prisma/schema.prisma - - src/lib/db.ts -tech-stack: - added: - - prisma - - zod -patterns-established: - - Repository pattern - - Dependency injection -key-decisions: - - Use Prisma over Drizzle: Better DX and ecosystem - - Single database: Start simple, shard later ---- - -# Summary - -Full summary content here. -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct'); - assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted'); - assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted'); - assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted'); - assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted'); - assert.strictEqual(output.decisions.length, 2, 'decisions extracted'); - }); - - test('selective extraction with --fields', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -one-liner: Set up database -key-files: - - prisma/schema.prisma -tech-stack: - added: - - prisma -patterns-established: - - Repository pattern -key-decisions: - - Use Prisma: Better DX ---- -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included'); - assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included'); - assert.strictEqual(output.tech_added, undefined, 'tech_added excluded'); - assert.strictEqual(output.patterns, undefined, 'patterns excluded'); - assert.strictEqual(output.decisions, undefined, 'decisions excluded'); - }); - - test('handles missing frontmatter fields gracefully', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -one-liner: Minimal summary ---- - -# Summary -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted'); - assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty'); - assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty'); - assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty'); - assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty'); - }); - - test('parses key-decisions with rationale', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - - fs.writeFileSync( - path.join(phaseDir, '01-01-SUMMARY.md'), - `--- -key-decisions: - - Use Prisma: Better DX than alternatives - - JWT tokens: Stateless auth for scalability ---- -` - ); - - const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed'); - assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed'); - assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary'); - assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// init commands tests -// ───────────────────────────────────────────────────────────────────────────── - -describe('init commands', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('init execute-phase returns file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); - - const result = runGsdTools('init execute-phase 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.config_path, '.planning/config.json'); - }); - - test('init plan-phase returns file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); - fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research Findings'); - fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); - fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); - - const result = runGsdTools('init plan-phase 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); - assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); - assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); - assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); - assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); - }); - - test('init progress returns file paths', () => { - const result = runGsdTools('init progress', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.project_path, '.planning/PROJECT.md'); - assert.strictEqual(output.config_path, '.planning/config.json'); - }); - - test('init phase-op returns core and optional phase file paths', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); - fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research'); - fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); - fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); - - const result = runGsdTools('init phase-op 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.state_path, '.planning/STATE.md'); - assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); - assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); - assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); - assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); - assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); - assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); - }); - - test('init plan-phase omits optional paths if files missing', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - - const result = runGsdTools('init plan-phase 03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.context_path, undefined); - assert.strictEqual(output.research_path, undefined); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// roadmap analyze command -// ───────────────────────────────────────────────────────────────────────────── - -describe('roadmap analyze command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('missing ROADMAP.md returns error', () => { - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `Command should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.error, 'ROADMAP.md not found'); - }); - - test('parses phases with goals and disk status', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 - -### Phase 1: Foundation -**Goal:** Set up infrastructure - -### Phase 2: Authentication -**Goal:** Add user auth - -### Phase 3: Features -**Goal:** Build core features -` - ); - - // Create phase dirs with varying completion - 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 p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication'); - fs.mkdirSync(p2, { recursive: true }); - fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); - - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase_count, 3, 'should find 3 phases'); - assert.strictEqual(output.phases[0].disk_status, 'complete', 'phase 1 complete'); - assert.strictEqual(output.phases[1].disk_status, 'planned', 'phase 2 planned'); - assert.strictEqual(output.phases[2].disk_status, 'no_directory', 'phase 3 no directory'); - assert.strictEqual(output.completed_phases, 1, '1 phase complete'); - assert.strictEqual(output.total_plans, 2, '2 total plans'); - assert.strictEqual(output.total_summaries, 1, '1 total summary'); - assert.strictEqual(output.progress_percent, 50, '50% complete'); - assert.strictEqual(output.current_phase, '2', 'current phase is 2'); - }); - - test('extracts goals and dependencies', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap - -### Phase 1: Setup -**Goal:** Initialize project -**Depends on:** Nothing - -### Phase 2: Build -**Goal:** Build features -**Depends on:** Phase 1 -` - ); - - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phases[0].goal, 'Initialize project'); - assert.strictEqual(output.phases[0].depends_on, 'Nothing'); - assert.strictEqual(output.phases[1].goal, 'Build features'); - assert.strictEqual(output.phases[1].depends_on, 'Phase 1'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// phase add 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'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 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('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'); - }); - - 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('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'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// phase complete command -// ───────────────────────────────────────────────────────────────────────────── - -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 = runGsdTools('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'); - }); - - 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 = runGsdTools('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('Milestone complete'), 'status should be milestone complete'); - }); - - 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 = runGsdTools('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('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('phase complete 1', tmpDir); - assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// milestone complete command -// ───────────────────────────────────────────────────────────────────────────── - -describe('milestone complete command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('archives roadmap, requirements, creates MILESTONES.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), - `# Requirements\n\n- [ ] User auth\n- [ ] Dashboard\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\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-SUMMARY.md'), - `---\none-liner: Set up project infrastructure\n---\n# Summary\n` - ); - - const result = runGsdTools('milestone complete v1.0 --name MVP Foundation', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.version, 'v1.0'); - assert.strictEqual(output.phases, 1); - assert.ok(output.archived.roadmap, 'roadmap should be archived'); - assert.ok(output.archived.requirements, 'requirements should be archived'); - - // Verify archive files exist - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md')), - 'archived roadmap should exist' - ); - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md')), - 'archived requirements should exist' - ); - - // Verify MILESTONES.md created - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')), - 'MILESTONES.md should be created' - ); - const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); - assert.ok(milestones.includes('v1.0 MVP Foundation'), 'milestone entry should contain name'); - assert.ok(milestones.includes('Set up project infrastructure'), 'accomplishments should be listed'); - }); - - test('appends to existing MILESTONES.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'MILESTONES.md'), - `# Milestones\n\n## v0.9 Alpha (Shipped: 2025-01-01)\n\n---\n\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - - const result = runGsdTools('milestone complete v1.0 --name Beta', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); - assert.ok(milestones.includes('v0.9 Alpha'), 'existing entry should be preserved'); - assert.ok(milestones.includes('v1.0 Beta'), 'new entry should be appended'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// validate consistency command -// ───────────────────────────────────────────────────────────────────────────── - -describe('validate consistency command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('passes for consistent project', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n### Phase 1: A\n### Phase 2: B\n### Phase 3: C\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 }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.passed, true, 'should pass'); - assert.strictEqual(output.warning_count, 0, 'no warnings'); - }); - - test('warns about phase on disk but not in roadmap', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n### Phase 1: A\n` - ); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-orphan'), { recursive: true }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok(output.warning_count > 0, 'should have warnings'); - assert.ok( - output.warnings.some(w => w.includes('disk but not in ROADMAP')), - 'should warn about orphan directory' - ); - }); - - test('warns about gaps in phase numbering', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n### Phase 1: A\n### Phase 3: C\n` - ); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok( - output.warnings.some(w => w.includes('Gap in phase numbering')), - 'should warn about gap' - ); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// progress command -// ───────────────────────────────────────────────────────────────────────────── - -describe('progress command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('renders JSON progress', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 MVP\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'), '# Done'); - fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); - - const result = runGsdTools('progress json', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.total_plans, 2, '2 total plans'); - assert.strictEqual(output.total_summaries, 1, '1 summary'); - assert.strictEqual(output.percent, 50, '50%'); - assert.strictEqual(output.phases.length, 1, '1 phase'); - assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress'); - }); - - test('renders bar format', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - 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'), '# Done'); - - const result = runGsdTools('progress bar --raw', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - assert.ok(result.output.includes('1/1'), 'should include count'); - assert.ok(result.output.includes('100%'), 'should include 100%'); - }); - - test('renders table format', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 MVP\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'); - - const result = runGsdTools('progress table --raw', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - assert.ok(result.output.includes('Phase'), 'should have table header'); - assert.ok(result.output.includes('foundation'), 'should include phase name'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// todo complete command -// ───────────────────────────────────────────────────────────────────────────── - -describe('todo complete command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('moves todo from pending to completed', () => { - const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); - fs.mkdirSync(pendingDir, { recursive: true }); - fs.writeFileSync( - path.join(pendingDir, 'add-dark-mode.md'), - `title: Add dark mode\narea: ui\ncreated: 2025-01-01\n` - ); - - const result = runGsdTools('todo complete add-dark-mode.md', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.completed, true); - - // Verify moved - assert.ok( - !fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')), - 'should be removed from pending' - ); - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')), - 'should be in completed' - ); - - // Verify completion timestamp added - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'), - 'utf-8' - ); - assert.ok(content.startsWith('completed:'), 'should have completed timestamp'); - }); - - test('fails for nonexistent todo', () => { - const result = runGsdTools('todo complete nonexistent.md', tmpDir); - assert.ok(!result.success, 'should fail'); - assert.ok(result.error.includes('not found'), 'error mentions not found'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// scaffold command -// ───────────────────────────────────────────────────────────────────────────── - -describe('scaffold command', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('scaffolds context file', () => { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); - - const result = runGsdTools('scaffold context --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - - // Verify file content - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'), - 'utf-8' - ); - assert.ok(content.includes('Phase 3'), 'should reference phase number'); - assert.ok(content.includes('Decisions'), 'should have decisions section'); - assert.ok(content.includes('Discretion Areas'), 'should have discretion section'); - }); - - test('scaffolds UAT file', () => { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); - - const result = runGsdTools('scaffold uat --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'), - 'utf-8' - ); - assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading'); - assert.ok(content.includes('Test Results'), 'should have test results section'); - }); - - test('scaffolds verification file', () => { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); - - const result = runGsdTools('scaffold verification --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - - const content = fs.readFileSync( - path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'), - 'utf-8' - ); - assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading'); - }); - - test('scaffolds phase directory', () => { - const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, true); - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')), - 'directory should be created' - ); - }); - - test('does not overwrite existing files', () => { - const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content'); - - const result = runGsdTools('scaffold context --phase 3', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.created, false, 'should not overwrite'); - assert.strictEqual(output.reason, 'already_exists'); - }); -}); diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs new file mode 100644 index 000000000..a6d776d8e --- /dev/null +++ b/tests/commands.test.cjs @@ -0,0 +1,625 @@ +/** + * GSD Tools Tests - Commands + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('history-digest command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('empty phases directory returns valid schema', () => { + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + + assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object'); + assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array'); + assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array'); + }); + + test('nested frontmatter fields extracted correctly', () => { + // Create phase directory with SUMMARY containing nested frontmatter + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + const summaryContent = `--- +phase: "01" +name: "Foundation Setup" +dependency-graph: + provides: + - "Database schema" + - "Auth system" + affects: + - "API layer" +tech-stack: + added: + - "prisma" + - "jose" +patterns-established: + - "Repository pattern" + - "JWT auth flow" +key-decisions: + - "Use Prisma over Drizzle" + - "JWT in httpOnly cookies" +--- + +# Summary content here +`; + + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + + // Check nested dependency-graph.provides + assert.ok(digest.phases['01'], 'Phase 01 should exist'); + assert.deepStrictEqual( + digest.phases['01'].provides.sort(), + ['Auth system', 'Database schema'], + 'provides should contain nested values' + ); + + // Check nested dependency-graph.affects + assert.deepStrictEqual( + digest.phases['01'].affects, + ['API layer'], + 'affects should contain nested values' + ); + + // Check nested tech-stack.added + assert.deepStrictEqual( + digest.tech_stack.sort(), + ['jose', 'prisma'], + 'tech_stack should contain nested values' + ); + + // Check patterns-established (flat array) + assert.deepStrictEqual( + digest.phases['01'].patterns.sort(), + ['JWT auth flow', 'Repository pattern'], + 'patterns should be extracted' + ); + + // Check key-decisions + assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions'); + assert.ok( + digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'), + 'Should contain first decision' + ); + }); + + test('multiple phases merged into single digest', () => { + // Create phase 01 + const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.writeFileSync( + path.join(phase01Dir, '01-01-SUMMARY.md'), + `--- +phase: "01" +name: "Foundation" +provides: + - "Database" +patterns-established: + - "Pattern A" +key-decisions: + - "Decision 1" +--- +` + ); + + // Create phase 02 + const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api'); + fs.mkdirSync(phase02Dir, { recursive: true }); + fs.writeFileSync( + path.join(phase02Dir, '02-01-SUMMARY.md'), + `--- +phase: "02" +name: "API" +provides: + - "REST endpoints" +patterns-established: + - "Pattern B" +key-decisions: + - "Decision 2" +tech-stack: + added: + - "zod" +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + + // Both phases present + assert.ok(digest.phases['01'], 'Phase 01 should exist'); + assert.ok(digest.phases['02'], 'Phase 02 should exist'); + + // Decisions merged + assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total'); + + // Tech stack merged + assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod'); + }); + + test('malformed SUMMARY.md skipped gracefully', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + + // Valid summary + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +phase: "01" +provides: + - "Valid feature" +--- +` + ); + + // Malformed summary (no frontmatter) + fs.writeFileSync( + path.join(phaseDir, '01-02-SUMMARY.md'), + `# Just a heading +No frontmatter here +` + ); + + // Another malformed summary (broken YAML) + fs.writeFileSync( + path.join(phaseDir, '01-03-SUMMARY.md'), + `--- +broken: [unclosed +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`); + + const digest = JSON.parse(result.output); + assert.ok(digest.phases['01'], 'Phase 01 should exist'); + assert.ok( + digest.phases['01'].provides.includes('Valid feature'), + 'Valid feature should be extracted' + ); + }); + + test('flat provides field still works (backward compatibility)', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +phase: "01" +provides: + - "Direct provides" +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + assert.deepStrictEqual( + digest.phases['01'].provides, + ['Direct provides'], + 'Direct provides should work' + ); + }); + + test('inline array syntax supported', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +phase: "01" +provides: [Feature A, Feature B] +patterns-established: ["Pattern X", "Pattern Y"] +--- +` + ); + + const result = runGsdTools('history-digest', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const digest = JSON.parse(result.output); + assert.deepStrictEqual( + digest.phases['01'].provides.sort(), + ['Feature A', 'Feature B'], + 'Inline array should work' + ); + assert.deepStrictEqual( + digest.phases['01'].patterns.sort(), + ['Pattern X', 'Pattern Y'], + 'Inline quoted array should work' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// phases list command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('summary-extract command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing file returns error', () => { + const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.error, 'File not found', 'should report missing file'); + }); + + test('extracts all fields from SUMMARY.md', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Set up Prisma with User and Project models +key-files: + - prisma/schema.prisma + - src/lib/db.ts +tech-stack: + added: + - prisma + - zod +patterns-established: + - Repository pattern + - Dependency injection +key-decisions: + - Use Prisma over Drizzle: Better DX and ecosystem + - Single database: Start simple, shard later +--- + +# Summary + +Full summary content here. +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct'); + assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted'); + assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted'); + assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted'); + assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted'); + assert.strictEqual(output.decisions.length, 2, 'decisions extracted'); + }); + + test('selective extraction with --fields', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Set up database +key-files: + - prisma/schema.prisma +tech-stack: + added: + - prisma +patterns-established: + - Repository pattern +key-decisions: + - Use Prisma: Better DX +--- +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included'); + assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included'); + assert.strictEqual(output.tech_added, undefined, 'tech_added excluded'); + assert.strictEqual(output.patterns, undefined, 'patterns excluded'); + assert.strictEqual(output.decisions, undefined, 'decisions excluded'); + }); + + test('handles missing frontmatter fields gracefully', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Minimal summary +--- + +# Summary +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted'); + assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty'); + assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty'); + assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty'); + assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty'); + }); + + test('parses key-decisions with rationale', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +key-decisions: + - Use Prisma: Better DX than alternatives + - JWT tokens: Stateless auth for scalability +--- +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed'); + assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed'); + assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary'); + assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// init commands tests +// ───────────────────────────────────────────────────────────────────────────── + + +describe('progress command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('renders JSON progress', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 MVP\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'), '# Done'); + fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); + + const result = runGsdTools('progress json', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.total_plans, 2, '2 total plans'); + assert.strictEqual(output.total_summaries, 1, '1 summary'); + assert.strictEqual(output.percent, 50, '50%'); + assert.strictEqual(output.phases.length, 1, '1 phase'); + assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress'); + }); + + test('renders bar format', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n` + ); + 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'), '# Done'); + + const result = runGsdTools('progress bar --raw', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.output.includes('1/1'), 'should include count'); + assert.ok(result.output.includes('100%'), 'should include 100%'); + }); + + test('renders table format', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 MVP\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'); + + const result = runGsdTools('progress table --raw', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.output.includes('Phase'), 'should have table header'); + assert.ok(result.output.includes('foundation'), 'should include phase name'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// todo complete command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('todo complete command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('moves todo from pending to completed', () => { + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync( + path.join(pendingDir, 'add-dark-mode.md'), + `title: Add dark mode\narea: ui\ncreated: 2025-01-01\n` + ); + + const result = runGsdTools('todo complete add-dark-mode.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.completed, true); + + // Verify moved + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')), + 'should be removed from pending' + ); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')), + 'should be in completed' + ); + + // Verify completion timestamp added + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'), + 'utf-8' + ); + assert.ok(content.startsWith('completed:'), 'should have completed timestamp'); + }); + + test('fails for nonexistent todo', () => { + const result = runGsdTools('todo complete nonexistent.md', tmpDir); + assert.ok(!result.success, 'should fail'); + assert.ok(result.error.includes('not found'), 'error mentions not found'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// scaffold command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('scaffold command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('scaffolds context file', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); + + const result = runGsdTools('scaffold context --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + + // Verify file content + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'), + 'utf-8' + ); + assert.ok(content.includes('Phase 3'), 'should reference phase number'); + assert.ok(content.includes('Decisions'), 'should have decisions section'); + assert.ok(content.includes('Discretion Areas'), 'should have discretion section'); + }); + + test('scaffolds UAT file', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); + + const result = runGsdTools('scaffold uat --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'), + 'utf-8' + ); + assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading'); + assert.ok(content.includes('Test Results'), 'should have test results section'); + }); + + test('scaffolds verification file', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); + + const result = runGsdTools('scaffold verification --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'), + 'utf-8' + ); + assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading'); + }); + + test('scaffolds phase directory', () => { + const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, true); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')), + 'directory should be created' + ); + }); + + test('does not overwrite existing files', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content'); + + const result = runGsdTools('scaffold context --phase 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.created, false, 'should not overwrite'); + assert.strictEqual(output.reason, 'already_exists'); + }); +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs new file mode 100644 index 000000000..76a809b95 --- /dev/null +++ b/tests/helpers.cjs @@ -0,0 +1,40 @@ +/** + * GSD Tools Test Helpers + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + +// Helper to run gsd-tools command +function runGsdTools(args, cwd = process.cwd()) { + try { + const result = execSync(`node "${TOOLS_PATH}" ${args}`, { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true, output: result.trim() }; + } catch (err) { + return { + success: false, + output: err.stdout?.toString().trim() || '', + error: err.stderr?.toString().trim() || err.message, + }; + } +} + +// Create temp directory structure +function createTempProject() { + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-test-')); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + return tmpDir; +} + +function cleanup(tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); +} + +module.exports = { runGsdTools, createTempProject, cleanup, TOOLS_PATH }; diff --git a/tests/init.test.cjs b/tests/init.test.cjs new file mode 100644 index 000000000..ffbd860d6 --- /dev/null +++ b/tests/init.test.cjs @@ -0,0 +1,105 @@ +/** + * GSD Tools Tests - Init + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('init commands', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('init execute-phase returns file paths', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan'); + + const result = runGsdTools('init execute-phase 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.config_path, '.planning/config.json'); + }); + + test('init plan-phase returns file paths', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); + fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research Findings'); + fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); + fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); + + const result = runGsdTools('init plan-phase 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); + assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); + assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); + assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); + assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); + }); + + test('init progress returns file paths', () => { + const result = runGsdTools('init progress', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.project_path, '.planning/PROJECT.md'); + assert.strictEqual(output.config_path, '.planning/config.json'); + }); + + test('init phase-op returns core and optional phase file paths', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context'); + fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research'); + fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification'); + fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT'); + + const result = runGsdTools('init phase-op 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.state_path, '.planning/STATE.md'); + assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md'); + assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md'); + assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md'); + assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md'); + assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md'); + assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md'); + }); + + test('init plan-phase omits optional paths if files missing', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + + const result = runGsdTools('init plan-phase 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.context_path, undefined); + assert.strictEqual(output.research_path, undefined); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// roadmap analyze command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/milestone.test.cjs b/tests/milestone.test.cjs new file mode 100644 index 000000000..bb03c87bf --- /dev/null +++ b/tests/milestone.test.cjs @@ -0,0 +1,98 @@ +/** + * GSD Tools Tests - Milestone + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('milestone complete command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('archives roadmap, requirements, creates MILESTONES.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), + `# Requirements\n\n- [ ] User auth\n- [ ] Dashboard\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\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-SUMMARY.md'), + `---\none-liner: Set up project infrastructure\n---\n# Summary\n` + ); + + const result = runGsdTools('milestone complete v1.0 --name MVP Foundation', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.version, 'v1.0'); + assert.strictEqual(output.phases, 1); + assert.ok(output.archived.roadmap, 'roadmap should be archived'); + assert.ok(output.archived.requirements, 'requirements should be archived'); + + // Verify archive files exist + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md')), + 'archived roadmap should exist' + ); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md')), + 'archived requirements should exist' + ); + + // Verify MILESTONES.md created + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')), + 'MILESTONES.md should be created' + ); + const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); + assert.ok(milestones.includes('v1.0 MVP Foundation'), 'milestone entry should contain name'); + assert.ok(milestones.includes('Set up project infrastructure'), 'accomplishments should be listed'); + }); + + test('appends to existing MILESTONES.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'MILESTONES.md'), + `# Milestones\n\n## v0.9 Alpha (Shipped: 2025-01-01)\n\n---\n\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + const result = runGsdTools('milestone complete v1.0 --name Beta', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); + assert.ok(milestones.includes('v0.9 Alpha'), 'existing entry should be preserved'); + assert.ok(milestones.includes('v1.0 Beta'), 'new entry should be appended'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// validate consistency command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs new file mode 100644 index 000000000..86380d705 --- /dev/null +++ b/tests/phase.test.cjs @@ -0,0 +1,1013 @@ +/** + * GSD Tools Tests - Phase + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +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 +// ───────────────────────────────────────────────────────────────────────────── + + +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'); + }); + + 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', () => { + 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 +--- + +## Task 1: Schema +` + ); + + fs.writeFileSync( + path.join(phaseDir, '03-02-PLAN.md'), + `--- +wave: 1 +autonomous: true +objective: Auth setup +--- + +## Task 1: JWT +` + ); + + fs.writeFileSync( + path.join(phaseDir, '03-03-PLAN.md'), + `--- +wave: 2 +autonomous: false +objective: API routes +--- + +## Task 1: Routes +` + ); + + 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'); + }); + + 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('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'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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('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'); + }); + + 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('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'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// phase complete command +// ───────────────────────────────────────────────────────────────────────────── + + +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 = runGsdTools('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'); + }); + + 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 = runGsdTools('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('Milestone complete'), 'status should be milestone complete'); + }); + + 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 = runGsdTools('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('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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('phase complete 1', tmpDir); + assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// milestone complete command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs new file mode 100644 index 000000000..8e3304734 --- /dev/null +++ b/tests/roadmap.test.cjs @@ -0,0 +1,265 @@ +/** + * GSD Tools Tests - Roadmap + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('roadmap get-phase command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('extracts phase section from ROADMAP.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +## Phases + +### Phase 1: Foundation +**Goal:** Set up project infrastructure +**Plans:** 2 plans + +Some description here. + +### Phase 2: API +**Goal:** Build REST API +**Plans:** 3 plans +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'phase should be found'); + assert.strictEqual(output.phase_number, '1', 'phase number correct'); + assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); + assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); + }); + + test('returns not found for missing phase', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +### Phase 1: Foundation +**Goal:** Set up project +` + ); + + const result = runGsdTools('roadmap get-phase 5', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, false, 'phase should not be found'); + }); + + test('handles decimal phase numbers', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 2: Main +**Goal:** Main work + +### Phase 2.1: Hotfix +**Goal:** Emergency fix +` + ); + + const result = runGsdTools('roadmap get-phase 2.1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'decimal phase should be found'); + assert.strictEqual(output.phase_name, 'Hotfix', 'phase name correct'); + assert.strictEqual(output.goal, 'Emergency fix', 'goal extracted'); + }); + + test('extracts full section content', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 1: Setup +**Goal:** Initialize everything + +This phase covers: +- Database setup +- Auth configuration +- CI/CD pipeline + +### Phase 2: Build +**Goal:** Build features +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.section.includes('Database setup'), 'section includes description'); + assert.ok(output.section.includes('CI/CD pipeline'), 'section includes all bullets'); + assert.ok(!output.section.includes('Phase 2'), 'section does not include next phase'); + }); + + test('handles missing ROADMAP.md gracefully', () => { + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, false, 'should return not found'); + assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why'); + }); + + test('accepts ## phase headers (two hashes)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +## Phase 1: Foundation +**Goal:** Set up project infrastructure +**Plans:** 2 plans + +## Phase 2: API +**Goal:** Build REST API +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'phase with ## header should be found'); + assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted'); + assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); + }); + + test('detects malformed ROADMAP with summary list but no detail sections', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +## Phases + +- [ ] **Phase 1: Foundation** - Set up project +- [ ] **Phase 2: API** - Build REST API +` + ); + + const result = runGsdTools('roadmap get-phase 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, false, 'phase should not be found'); + assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap'); + assert.ok(output.message.includes('missing'), 'should explain the issue'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// phase next-decimal command +// ───────────────────────────────────────────────────────────────────────────── + + +describe('roadmap analyze command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing ROADMAP.md returns error', () => { + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.error, 'ROADMAP.md not found'); + }); + + test('parses phases with goals and disk status', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0 + +### Phase 1: Foundation +**Goal:** Set up infrastructure + +### Phase 2: Authentication +**Goal:** Add user auth + +### Phase 3: Features +**Goal:** Build core features +` + ); + + // Create phase dirs with varying completion + 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 p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication'); + fs.mkdirSync(p2, { recursive: true }); + fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan'); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_count, 3, 'should find 3 phases'); + assert.strictEqual(output.phases[0].disk_status, 'complete', 'phase 1 complete'); + assert.strictEqual(output.phases[1].disk_status, 'planned', 'phase 2 planned'); + assert.strictEqual(output.phases[2].disk_status, 'no_directory', 'phase 3 no directory'); + assert.strictEqual(output.completed_phases, 1, '1 phase complete'); + assert.strictEqual(output.total_plans, 2, '2 total plans'); + assert.strictEqual(output.total_summaries, 1, '1 total summary'); + assert.strictEqual(output.progress_percent, 50, '50% complete'); + assert.strictEqual(output.current_phase, '2', 'current phase is 2'); + }); + + test('extracts goals and dependencies', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 1: Setup +**Goal:** Initialize project +**Depends on:** Nothing + +### Phase 2: Build +**Goal:** Build features +**Depends on:** Phase 1 +` + ); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].goal, 'Initialize project'); + assert.strictEqual(output.phases[0].depends_on, 'Nothing'); + assert.strictEqual(output.phases[1].goal, 'Build features'); + assert.strictEqual(output.phases[1].depends_on, 'Phase 1'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// phase add command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/state.test.cjs b/tests/state.test.cjs new file mode 100644 index 000000000..6b22460ca --- /dev/null +++ b/tests/state.test.cjs @@ -0,0 +1,156 @@ +/** + * GSD Tools Tests - State + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('state-snapshot command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing STATE.md returns error', () => { + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file'); + }); + + test('extracts basic fields from STATE.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 +**Current Phase Name:** API Layer +**Total Phases:** 6 +**Current Plan:** 03-02 +**Total Plans in Phase:** 3 +**Status:** In progress +**Progress:** 45% +**Last Activity:** 2024-01-15 +**Last Activity Description:** Completed 03-01-PLAN.md +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.current_phase, '03', 'current phase extracted'); + assert.strictEqual(output.current_phase_name, 'API Layer', 'phase name extracted'); + assert.strictEqual(output.total_phases, 6, 'total phases extracted'); + assert.strictEqual(output.current_plan, '03-02', 'current plan extracted'); + assert.strictEqual(output.total_plans_in_phase, 3, 'total plans extracted'); + assert.strictEqual(output.status, 'In progress', 'status extracted'); + assert.strictEqual(output.progress_percent, 45, 'progress extracted'); + assert.strictEqual(output.last_activity, '2024-01-15', 'last activity date extracted'); + }); + + test('extracts decisions table', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 01 + +## Decisions Made + +| Phase | Decision | Rationale | +|-------|----------|-----------| +| 01 | Use Prisma | Better DX than raw SQL | +| 02 | JWT auth | Stateless authentication | +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.decisions.length, 2, 'should have 2 decisions'); + assert.strictEqual(output.decisions[0].phase, '01', 'first decision phase'); + assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'first decision summary'); + assert.strictEqual(output.decisions[0].rationale, 'Better DX than raw SQL', 'first decision rationale'); + }); + + test('extracts blockers list', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 + +## Blockers + +- Waiting for API credentials +- Need design review for dashboard +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.deepStrictEqual(output.blockers, [ + 'Waiting for API credentials', + 'Need design review for dashboard', + ], 'blockers extracted'); + }); + + test('extracts session continuity info', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 + +## Session + +**Last Date:** 2024-01-15 +**Stopped At:** Phase 3, Plan 2, Task 1 +**Resume File:** .planning/phases/03-api/03-02-PLAN.md +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.session.last_date, '2024-01-15', 'session date extracted'); + assert.strictEqual(output.session.stopped_at, 'Phase 3, Plan 2, Task 1', 'stopped at extracted'); + assert.strictEqual(output.session.resume_file, '.planning/phases/03-api/03-02-PLAN.md', 'resume file extracted'); + }); + + test('handles paused_at field', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 03 +**Paused At:** Phase 3, Plan 1, Task 2 - mid-implementation +` + ); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.paused_at, 'Phase 3, Plan 1, Task 2 - mid-implementation', 'paused_at extracted'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// summary-extract command +// ───────────────────────────────────────────────────────────────────────────── + diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs new file mode 100644 index 000000000..7da2858e1 --- /dev/null +++ b/tests/verify.test.cjs @@ -0,0 +1,80 @@ +/** + * GSD Tools Tests - Verify + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('validate consistency command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('passes for consistent project', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n### Phase 1: A\n### Phase 2: B\n### Phase 3: C\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 }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.passed, true, 'should pass'); + assert.strictEqual(output.warning_count, 0, 'no warnings'); + }); + + test('warns about phase on disk but not in roadmap', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n### Phase 1: A\n` + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-orphan'), { recursive: true }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.warning_count > 0, 'should have warnings'); + assert.ok( + output.warnings.some(w => w.includes('disk but not in ROADMAP')), + 'should warn about orphan directory' + ); + }); + + test('warns about gaps in phase numbering', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n### Phase 1: A\n### Phase 3: C\n` + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.includes('Gap in phase numbering')), + 'should warn about gap' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// progress command +// ───────────────────────────────────────────────────────────────────────────── +