Files
get-shit-done/tests/phase.test.cjs
Tom Boucher 7f11362952 fix(phase): scan .planning/phases/ for orphan dirs in phase add (#2034)
cmdPhaseAdd computed maxPhase from ROADMAP.md only, allowing orphan
directories on disk (untracked in roadmap) to silently collide with
newly added phases. The new phase's mkdirSync succeeded against the
existing directory, contaminating it with fresh content.

Fix: take max(roadmapMax, diskMax) where diskMax scans
.planning/phases/ and strips optional project_code prefix before
parsing the leading integer. Backlog orphans (>=999) are skipped.

Adds 3 regression tests covering:
- orphan dir with number higher than roadmap max
- prefixed orphan dirs (project_code-NN-slug)
- no collision when orphan number is lower than roadmap max

Fixes #2026

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:04:33 -04:00

2337 lines
87 KiB
JavaScript

/**
* GSD Tools Tests - Phase
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
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');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase-plan-index — canonical XML format (template-aligned)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase-plan-index canonical format', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('files_modified: underscore key is parsed correctly', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
wave: 1
autonomous: true
files_modified: [src/App.tsx, src/index.ts]
---
<objective>
Build main application shell
Purpose: Entry point
Output: App component
</objective>
<tasks>
<task type="auto">
<name>Task 1: Create App component</name>
<files>src/App.tsx</files>
<action>Create component</action>
<verify>npm run build</verify>
<done>Component renders</done>
</task>
</tasks>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.plans[0].files_modified,
['src/App.tsx', 'src/index.ts'],
'files_modified with underscore should be parsed'
);
});
test('objective: extracted from <objective> XML tag, not frontmatter', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
wave: 1
autonomous: true
files_modified: []
---
<objective>
Build main application shell
Purpose: Entry point for the SPA
Output: App.tsx with routing
</objective>
<tasks>
<task type="auto">
<name>Task 1: Scaffold</name>
<files>src/App.tsx</files>
<action>Create shell</action>
<verify>build passes</verify>
<done>App renders</done>
</task>
</tasks>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.plans[0].objective,
'Build main application shell',
'objective should come from <objective> XML tag first line'
);
});
test('task_count: counts <task> XML tags', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
wave: 1
autonomous: true
files_modified: []
---
<objective>
Create UI components
</objective>
<tasks>
<task type="auto">
<name>Task 1: Header</name>
<files>src/Header.tsx</files>
<action>Create header</action>
<verify>build</verify>
<done>Header renders</done>
</task>
<task type="auto">
<name>Task 2: Footer</name>
<files>src/Footer.tsx</files>
<action>Create footer</action>
<verify>build</verify>
<done>Footer renders</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>UI components</what-built>
<how-to-verify>Visit localhost:3000</how-to-verify>
<resume-signal>Type approved</resume-signal>
</task>
</tasks>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.plans[0].task_count,
3,
'should count all 3 <task> XML tags'
);
});
test('all three fields work together in canonical plan format', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '04-01-PLAN.md'),
`---
phase: 04-ui
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/components/Chat.tsx, src/app/api/chat/route.ts]
autonomous: true
requirements: [R1, R2]
---
<objective>
Implement complete Chat feature as vertical slice.
Purpose: Self-contained chat that can run parallel to other features.
Output: Chat component, API endpoints.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create Chat component</name>
<files>src/components/Chat.tsx</files>
<action>Build chat UI with message list and input</action>
<verify>npm run build</verify>
<done>Chat component renders messages</done>
</task>
<task type="auto">
<name>Task 2: Create Chat API</name>
<files>src/app/api/chat/route.ts</files>
<action>GET /api/chat and POST /api/chat endpoints</action>
<verify>curl tests pass</verify>
<done>CRUD operations work</done>
</task>
</tasks>
<verification>
- [ ] npm run build succeeds
- [ ] API endpoints respond correctly
</verification>
`
);
const result = runGsdTools('phase-plan-index 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const plan = output.plans[0];
assert.strictEqual(plan.objective, 'Implement complete Chat feature as vertical slice.', 'objective from XML tag');
assert.deepStrictEqual(plan.files_modified, ['src/components/Chat.tsx', 'src/app/api/chat/route.ts'], 'files_modified with underscore');
assert.strictEqual(plan.task_count, 2, 'task_count from <task> XML tags');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// state-snapshot command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase add command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('adds phase after highest existing', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
---
`
);
const result = runGsdTools('phase add User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
assert.strictEqual(output.slug, 'user-dashboard');
// Verify directory created
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
'directory should be created'
);
// Verify ROADMAP updated
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
assert.ok(roadmap.includes('**Depends on:** Phase 2'), 'should depend on previous');
});
test('handles empty roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
const result = runGsdTools('phase add Initial Setup', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 1, 'should be phase 1');
});
test('phase add includes **Requirements**: TBD in new ROADMAP entry', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
);
const result = runGsdTools('phase add User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('**Requirements**: TBD'), 'new phase entry should include Requirements TBD');
});
test('skips 999.x backlog phases when calculating next phase number', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
### Phase 3: UI
**Goal:** Build UI
### Phase 999.1: Future Idea A
**Goal:** Backlog item
### Phase 999.2: Future Idea B
**Goal:** Backlog item
---
`
);
const result = runGsdTools('phase add Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 4, 'should be phase 4, not 1000');
assert.strictEqual(output.slug, 'dashboard');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-dashboard')),
'directory should be 04-dashboard, not 1000-dashboard'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add — orphan directory collision prevention (#2026)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase add — orphan directory collision prevention (#2026)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('orphan directory with higher number than ROADMAP pushes maxPhase up', () => {
// Orphan directory 05-orphan exists on disk but is NOT in ROADMAP.md
const orphanDir = path.join(tmpDir, '.planning', 'phases', '05-orphan');
fs.mkdirSync(orphanDir, { recursive: true });
fs.writeFileSync(path.join(orphanDir, 'SUMMARY.md'), 'existing work');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'## Milestone v1',
'### Phase 1: First phase',
'**Plans:** 0 plans',
'---',
].join('\n')
);
const result = runGsdTools('phase add dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// ROADMAP max is 1, but orphan 05-orphan means disk max is 5 → new phase = 6
assert.strictEqual(output.phase_number, 6, 'should be phase 6 (orphan 05 pushes max to 5)');
// The new directory must be 06-dashboard, not 02-dashboard
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard')),
'new phase directory must be 06-dashboard, not collide with orphan 05-orphan'
);
// The orphan directory must be untouched
assert.ok(
fs.existsSync(path.join(orphanDir, 'SUMMARY.md')),
'orphan directory content must be preserved (not overwritten)'
);
});
test('orphan directories with 999.x prefix are skipped when calculating disk max', () => {
// 999.x backlog orphans must not inflate the next sequential phase number
const backlogOrphan = path.join(tmpDir, '.planning', 'phases', '999-backlog-stuff');
fs.mkdirSync(backlogOrphan, { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'### Phase 1: Foundation',
'**Plans:** 0 plans',
'---',
].join('\n')
);
const result = runGsdTools('phase add new-feature', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// ROADMAP max is 1, disk orphan is 999 (backlog) → should be ignored → new phase = 2
assert.strictEqual(output.phase_number, 2, 'backlog 999.x orphan must not inflate phase count');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-new-feature')),
'new phase directory should be 02-new-feature'
);
});
test('project_code prefix in orphan directory name is stripped before comparing', () => {
// Orphan directory has project_code prefix e.g. CK-05-orphan
const orphanDir = path.join(tmpDir, '.planning', 'phases', 'CK-05-old-feature');
fs.mkdirSync(orphanDir, { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'CK' })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'### Phase 1: Foundation',
'**Plans:** 0 plans',
'---',
].join('\n')
);
const result = runGsdTools('phase add new-feature', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// ROADMAP max is 1, disk has CK-05-old-feature → strip prefix → disk max is 5 → new phase = 6
assert.strictEqual(output.phase_number, 6, 'project_code prefix must be stripped before disk max calculation');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-06-new-feature')),
'new phase directory must be CK-06-new-feature'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add with project_code prefix
// ─────────────────────────────────────────────────────────────────────────────
describe('phase add with project_code', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('prefixes phase directory with project_code', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'CK' })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
);
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-user-dashboard')),
'directory should have CK- prefix'
);
});
test('no prefix when project_code is null', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: null })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
);
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-user-dashboard')),
'directory should have no prefix'
);
});
test('find-phase resolves prefixed directories', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'should find prefixed phase');
assert.strictEqual(output.phase_number, '01', 'should extract numeric phase number');
});
test('phases list sorts prefixed directories correctly', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-api'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-03-ui'), { recursive: true });
const result = runGsdTools('phases list', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.directories,
['CK-01-foundation', 'CK-02-api', 'CK-03-ui'],
'prefixed phases should sort numerically'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase insert command
// ─────────────────────────────────────────────────────────────────────────────
describe('phase insert command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('inserts decimal phase after target', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '01.1', 'should be 01.1');
assert.strictEqual(output.after_phase, '1');
// Verify directory
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01.1-fix-critical-bug')),
'decimal phase directory should be created'
);
// Verify ROADMAP
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Phase 01.1: Fix Critical Bug (INSERTED)'), 'roadmap should include inserted phase');
});
test('increments decimal when siblings exist', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: API
**Goal:** Build API
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01.1-hotfix'), { recursive: true });
const result = runGsdTools('phase insert 1 Another Fix', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '01.2', 'should be 01.2');
});
test('rejects missing phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: Test\n**Goal:** Test\n`
);
const result = runGsdTools('phase insert 99 Fix Something', tmpDir);
assert.ok(!result.success, 'should fail for missing phase');
assert.ok(result.error.includes('not found'), 'error mentions not found');
});
test('handles padding mismatch between input and roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
## Phase 09.05: Existing Decimal Phase
**Goal:** Test padding
## Phase 09.1: Next Phase
**Goal:** Test
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '09.05-existing'), { recursive: true });
// Pass unpadded "9.05" but roadmap has "09.05"
const result = runGsdTools('phase insert 9.05 Padding Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.after_phase, '9.05');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('(INSERTED)'), 'roadmap should include inserted phase');
});
test('phase insert includes **Requirements**: TBD in new ROADMAP entry', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('**Requirements**: TBD'), 'inserted phase entry should include Requirements TBD');
});
test('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}`);
});
test('returns requirements_updated field in result', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Auth
### Phase 1: Auth
**Goal:** User authentication
**Requirements:** AUTH-01
**Plans:** 1 plans
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [ ] **AUTH-01**: User can sign up
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 1 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.requirements_updated, true, 'requirements_updated should be true');
});
test('handles In Progress status in traceability table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Auth
### Phase 1: Auth
**Goal:** User authentication
**Requirements:** AUTH-01, AUTH-02
**Plans:** 1 plans
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [ ] **AUTH-01**: User can sign up
- [ ] **AUTH-02**: User can log in
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 1 | In Progress |
| AUTH-02 | Phase 1 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = 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');
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'In Progress should become Complete');
assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'Pending should become Complete');
});
test('scoped regex does not cross phase boundaries', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Setup
- [ ] Phase 2: Auth
### Phase 1: Setup
**Goal:** Project setup
**Plans:** 1 plans
### Phase 2: Auth
**Goal:** User authentication
**Requirements:** AUTH-01
**Plans:** 0 plans
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [ ] **AUTH-01**: User can sign up
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 2 | Pending |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Setup\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// Phase 1 has no Requirements field, so Phase 2's AUTH-01 should NOT be updated
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
assert.ok(req.includes('- [ ] **AUTH-01**'), 'AUTH-01 should remain unchecked (belongs to Phase 2)');
assert.ok(req.includes('| AUTH-01 | Phase 2 | Pending |'), 'AUTH-01 should remain Pending (belongs to Phase 2)');
});
test('handles multi-level decimal phase without regex crash', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [x] Phase 3: Lorem
- [x] Phase 3.2: Ipsum
- [ ] Phase 3.2.1: Dolor Sit
- [ ] Phase 4: Amet
### Phase 3: Lorem
**Goal:** Setup
**Plans:** 1/1 plans complete
**Requirements:** LOR-01
### Phase 3.2: Ipsum
**Goal:** Build
**Plans:** 1/1 plans complete
**Requirements:** IPS-01
### Phase 03.2.1: Dolor Sit Polish (INSERTED)
**Goal:** Polish
**Plans:** 1/1 plans complete
### Phase 4: Amet
**Goal:** Deliver
**Requirements:** AMT-01: Filter items by category with AND logic (items matching ALL selected categories)
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
- [ ] **LOR-01**: Lorem database schema
- [ ] **IPS-01**: Ipsum rendering engine
- [ ] **AMT-01**: Filter items by category
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State
**Current Phase:** 03.2.1
**Current Phase Name:** Dolor Sit Polish
**Status:** Execution complete
**Current Plan:** 03.2.1-01
**Last Activity:** 2025-01-01
**Last Activity Description:** Working
`
);
const p32 = path.join(tmpDir, '.planning', 'phases', '03.2-ipsum');
const p321 = path.join(tmpDir, '.planning', 'phases', '03.2.1-dolor-sit');
const p4 = path.join(tmpDir, '.planning', 'phases', '04-amet');
fs.mkdirSync(p32, { recursive: true });
fs.mkdirSync(p321, { recursive: true });
fs.mkdirSync(p4, { recursive: true });
fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 03.2.1', tmpDir);
assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`);
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
assert.ok(req.includes('- [ ] **AMT-01**'), 'AMT-01 should remain unchanged');
});
test('preserves Milestone column in 5-column progress table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 1 plans
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Foundation | v1.0 | 0/1 | Planned | |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
assert.ok(rowMatch, 'table row should exist');
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
assert.strictEqual(cells.length, 5, 'should have 5 columns');
assert.strictEqual(cells[1], 'v1.0', 'Milestone column should be preserved');
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
});
test('updates STATE.md with plain format fields (no bold)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 1: Only\n**Goal:** Test\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\nPhase: 1 of 1 (Only)\nStatus: In progress\nPlan: 01-01\nLast Activity: 2025-01-01\nLast Activity Description: Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-only');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('Milestone complete'), 'plain Status field should be updated');
assert.ok(state.includes('Not started'), 'plain Plan field should be updated');
// Verify compound format preserved
assert.ok(state.match(/Phase:.*of\s+1/), 'should preserve "of N" in compound Phase format');
});
test('updates Plans Complete column in 4-column progress table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
- [ ] Phase 2: API
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 1 plans
### Phase 2: API
**Goal:** Build API
## Progress
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Foundation | 0/1 | Not started | - |
| 2. API | 0/1 | Not started | - |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
assert.ok(rowMatch, 'table row should exist');
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
assert.strictEqual(cells.length, 4, 'should have 4 columns');
assert.strictEqual(cells[1], '1/1', 'Plans Complete column should be updated to 1/1');
assert.ok(cells[2].includes('Complete'), 'Status column should be Complete');
});
test('updates Plans Complete column in 5-column progress table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 1 plans
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Foundation | v1.0 | 0/1 | Planned | |
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
assert.ok(rowMatch, 'table row should exist');
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
assert.strictEqual(cells.length, 5, 'should have 5 columns');
assert.strictEqual(cells[2], '1/1', 'Plans Complete column should be updated to 1/1');
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
});
test('marks plan-level checkboxes on phase complete', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md \u2014 Schema migration
- [ ] 01-02-PLAN.md \u2014 Auth setup
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 checkbox should be checked');
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 checkbox should be checked');
assert.ok(!roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should not remain unchecked');
assert.ok(!roadmap.includes('[ ] 01-02-PLAN.md'), 'plan 01-02 should not remain unchecked');
});
test('marks bold-wrapped plan-level checkboxes on phase complete', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] Phase 1: Foundation
### Phase 1: Foundation
**Goal:** Setup
**Plans:** 2 plans
Plans:
- [ ] **01-01**: Schema migration
- [ ] **01-02**: Auth setup
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('[x] **01-01**'), 'bold plan 01-01 checkbox should be checked');
assert.ok(roadmap.includes('[x] **01-02**'), 'bold plan 01-02 checkbox should be checked');
assert.ok(!roadmap.includes('[ ] **01-01**'), 'bold plan 01-01 should not remain unchecked');
assert.ok(!roadmap.includes('[ ] **01-02**'), 'bold plan 01-02 should not remain unchecked');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// comparePhaseNum and normalizePhaseName (imported directly)
// ─────────────────────────────────────────────────────────────────────────────
const { comparePhaseNum, normalizePhaseName } = require('../get-shit-done/bin/lib/core.cjs');
describe('comparePhaseNum', () => {
test('sorts integer phases numerically', () => {
assert.ok(comparePhaseNum('2', '10') < 0);
assert.ok(comparePhaseNum('10', '2') > 0);
assert.strictEqual(comparePhaseNum('5', '5'), 0);
});
test('sorts decimal phases correctly', () => {
assert.ok(comparePhaseNum('12', '12.1') < 0);
assert.ok(comparePhaseNum('12.1', '12.2') < 0);
assert.ok(comparePhaseNum('12.2', '13') < 0);
});
test('sorts letter-suffix phases correctly', () => {
assert.ok(comparePhaseNum('12', '12A') < 0);
assert.ok(comparePhaseNum('12A', '12B') < 0);
assert.ok(comparePhaseNum('12B', '13') < 0);
});
test('sorts hybrid phases correctly', () => {
assert.ok(comparePhaseNum('12A', '12A.1') < 0);
assert.ok(comparePhaseNum('12A.1', '12A.2') < 0);
assert.ok(comparePhaseNum('12A.2', '12B') < 0);
});
test('handles full sort order', () => {
const phases = ['13', '12B', '12A.2', '12', '12.1', '12A', '12A.1', '12.2'];
phases.sort(comparePhaseNum);
assert.deepStrictEqual(phases, ['12', '12.1', '12.2', '12A', '12A.1', '12A.2', '12B', '13']);
});
test('handles directory names with slugs', () => {
const dirs = ['13-deploy', '12B-hotfix', '12A.1-bugfix', '12-foundation', '12.1-inserted', '12A-split'];
dirs.sort(comparePhaseNum);
assert.deepStrictEqual(dirs, [
'12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'
]);
});
test('case insensitive letter matching', () => {
assert.ok(comparePhaseNum('12a', '12B') < 0);
assert.ok(comparePhaseNum('12A', '12b') < 0);
assert.strictEqual(comparePhaseNum('12a', '12A'), 0);
});
test('sorts multi-level decimal phases correctly', () => {
assert.ok(comparePhaseNum('3.2', '3.2.1') < 0);
assert.ok(comparePhaseNum('3.2.1', '3.2.2') < 0);
assert.ok(comparePhaseNum('3.2.1', '3.3') < 0);
assert.ok(comparePhaseNum('3.2.1', '4') < 0);
assert.strictEqual(comparePhaseNum('3.2.1', '3.2.1'), 0);
});
test('falls back to localeCompare for non-phase strings', () => {
const result = comparePhaseNum('abc', 'def');
assert.strictEqual(typeof result, 'number');
});
});
describe('normalizePhaseName', () => {
test('pads single-digit integers', () => {
assert.strictEqual(normalizePhaseName('3'), '03');
assert.strictEqual(normalizePhaseName('12'), '12');
});
test('handles decimal phases', () => {
assert.strictEqual(normalizePhaseName('3.1'), '03.1');
assert.strictEqual(normalizePhaseName('12.2'), '12.2');
});
test('handles letter-suffix phases', () => {
assert.strictEqual(normalizePhaseName('3A'), '03A');
assert.strictEqual(normalizePhaseName('12B'), '12B');
});
test('handles hybrid phases', () => {
assert.strictEqual(normalizePhaseName('3A.1'), '03A.1');
assert.strictEqual(normalizePhaseName('12A.2'), '12A.2');
});
test('preserves letter case', () => {
assert.strictEqual(normalizePhaseName('3a'), '03a');
assert.strictEqual(normalizePhaseName('12b.1'), '12b.1');
});
test('handles multi-level decimal phases', () => {
assert.strictEqual(normalizePhaseName('3.2.1'), '03.2.1');
assert.strictEqual(normalizePhaseName('12.3.4'), '12.3.4');
});
test('returns non-matching input unchanged', () => {
assert.strictEqual(normalizePhaseName('abc'), 'abc');
});
});
describe('letter-suffix phase sorting', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('lists letter-suffix phases in correct order', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12.1-inserted'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A-split'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A.1-bugfix'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12B-hotfix'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '13-deploy'), { recursive: true });
const result = runGsdTools('phases list', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.directories,
['12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'],
'letter-suffix phases should sort correctly'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// milestone-scoped next-phase in phase complete
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete milestone-scoped next-phase', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('finds next phase within milestone, ignoring prior milestone dirs', () => {
// ROADMAP lists phases 5-6 (current milestone v2.0)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'## Roadmap v2.0: Release',
'',
'- [ ] Phase 5: Auth',
'- [ ] Phase 6: Dashboard',
'',
'### Phase 5: Auth',
'**Goal:** Add authentication',
'**Plans:** 1 plans',
'',
'### Phase 6: Dashboard',
'**Goal:** Build dashboard',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n'
);
// Disk has dirs 01-06 (01-04 completed from prior milestone)
for (let i = 1; i <= 4; i++) {
const padded = String(i).padStart(2, '0');
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-old-phase`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
}
// Phase 5 — completing this one
const p5 = path.join(tmpDir, '.planning', 'phases', '05-auth');
fs.mkdirSync(p5, { recursive: true });
fs.writeFileSync(path.join(p5, '05-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p5, '05-01-SUMMARY.md'), '# Summary');
// Phase 6 — next phase in milestone
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard'), { recursive: true });
const result = runGsdTools('phase complete 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.is_last_phase, false, 'should NOT be last phase — phase 6 is in milestone');
assert.strictEqual(output.next_phase, '06', 'next phase should be 06');
});
test('detects last phase when only milestone phases are considered', () => {
// ROADMAP lists only phase 5 (current milestone)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'## Roadmap v2.0: Release',
'',
'### Phase 5: Auth',
'**Goal:** Add authentication',
'**Plans:** 1 plans',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n'
);
// Disk has dirs 01-06 but only 5 is in ROADMAP
for (let i = 1; i <= 6; i++) {
const padded = String(i).padStart(2, '0');
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
}
const result = runGsdTools('phase complete 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Without the fix, dirs 06 on disk would make is_last_phase=false
// With the fix, only phase 5 is in milestone, so it IS the last phase
assert.strictEqual(output.is_last_phase, true, 'should be last phase — only phase 5 is in milestone');
assert.strictEqual(output.next_phase, null, 'no next phase in milestone');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// exact token matching (no prefix collisions)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase resolution uses exact token matching', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('1009 must NOT match 1009A-feature-consistency when 1009 dir is absent', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency'));
fs.writeFileSync(path.join(phasesDir, '1009A-feature-consistency', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 1009', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'should NOT find phase 1009 when only 1009A exists');
});
test('1009 matches 1009-pipeline-accuracy-fix when both exist', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '1009-pipeline-accuracy-fix'));
fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency'));
fs.writeFileSync(path.join(phasesDir, '1009-pipeline-accuracy-fix', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 1009', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'should find phase 1009');
assert.ok(
output.directory.includes('1009-pipeline-accuracy-fix'),
`should match 1009-pipeline-accuracy-fix, got: ${output.directory}`
);
});
test('999.6 must NOT match 999.60-episode-processing when 999.6 dir is absent', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing'));
fs.writeFileSync(path.join(phasesDir, '999.60-episode-processing', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 999.6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'should NOT find phase 999.6 when only 999.60 exists');
});
test('999.6 matches 999.6-ground-truth-dataset when both exist', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '999.6-ground-truth-dataset'));
fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing'));
fs.writeFileSync(path.join(phasesDir, '999.6-ground-truth-dataset', 'PLAN.md'), '# Plan');
const result = runGsdTools('find-phase 999.6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'should find phase 999.6');
assert.ok(
output.directory.includes('999.6-ground-truth-dataset'),
`should match 999.6-ground-truth-dataset, got: ${output.directory}`
);
});
test('normal non-colliding phases still resolve', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
fs.mkdirSync(path.join(phasesDir, '02-implementation'));
fs.writeFileSync(path.join(phasesDir, '01-foundation', 'PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phasesDir, '02-implementation', 'PLAN.md'), '# Plan');
const r1 = runGsdTools('find-phase 1', tmpDir);
assert.ok(r1.success, `Command failed for phase 1: ${r1.error}`);
const o1 = JSON.parse(r1.output);
assert.strictEqual(o1.found, true, 'should find phase 1');
assert.ok(o1.directory.includes('01-foundation'), `should match 01-foundation, got: ${o1.directory}`);
const r2 = runGsdTools('find-phase 2', tmpDir);
assert.ok(r2.success, `Command failed for phase 2: ${r2.error}`);
const o2 = JSON.parse(r2.output);
assert.strictEqual(o2.found, true, 'should find phase 2');
assert.ok(o2.directory.includes('02-implementation'), `should match 02-implementation, got: ${o2.directory}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase complete — Performance Metrics gate (Step 2 — Gate 4)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete updates Performance Metrics', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('after cmdPhaseComplete: Performance Metrics has updated total plans count', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 2\n**Status:** Executing Phase 2\n**Total Plans in Phase:** 3\n**Current Plan:** 3\n**Completed Phases:** 0\n**Total Phases:** 3\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n');
fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan 2\n');
fs.writeFileSync(path.join(phaseDir, '02-03-PLAN.md'), '# Plan 3\n');
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '02-03-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n`
);
const result = runGsdTools('phase complete 2', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3');
});
test('after cmdPhaseComplete: By Phase table has row for completed phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 1\n**Status:** Executing Phase 1\n**Total Plans in Phase:** 2\n**Current Plan:** 2\n**Completed Phases:** 0\n**Total Phases:** 2\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n`
);
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(stateAfter.match(/\|\s*1\s*\|\s*2\s*\|/), 'By Phase table should have row for phase 1 with 2 plans');
// Row must appear BEFORE the next section, not after it (regression: empty table body regex)
const rowIdx = stateAfter.indexOf('| 1 |');
const accIdx = stateAfter.indexOf('## Accumulated Context');
if (accIdx !== -1) {
assert.ok(rowIdx < accIdx, 'By Phase row must appear before ## Accumulated Context section');
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// milestone complete command
// ─────────────────────────────────────────────────────────────────────────────