mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Refactoring: - Extract stateReplaceFieldWithFallback() to state.cjs as single source of truth for the try-primary-then-fallback pattern that was duplicated inline across phase.cjs, milestone.cjs, and state.cjs - Replace all inline bold-only regex patterns in cmdPhaseComplete with shared stateReplaceField/stateExtractField helpers — now supports both **Bold:** and plain Field: STATE.md formats (fixes the same bug as #924) - Replace inline bold-only regex patterns in cmdMilestoneComplete with shared helpers - Replace inline bold-only regex for Completed Phases/Total Phases/Progress counters in cmdPhaseComplete with stateExtractField/stateReplaceField - Replace inline bold-only Total Phases regex in cmdPhaseRemove with shared helpers Security: - Fix command injection surface in isGitIgnored (core.cjs): replace execSync with string concatenation with execFileSync using array arguments — prevents shell interpretation of special characters in file paths Tests (7 new): - 5 tests for stateReplaceFieldWithFallback: primary field, fallback, neither, preference, and plain format - 1 regression test: phase complete with plain-format STATE.md fields - 1 regression test: milestone complete with plain-format STATE.md fields 854 tests pass (was 847). No behavioral regressions.
1777 lines
63 KiB
JavaScript
1777 lines
63 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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('uppercases letters', () => {
|
|
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');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// milestone complete command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|