* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) Golden/read-only parity tests and registry alignment, query handler fixes (check-completion, state-mutation, commit, validate, summary, etc.), and WAITING.json dual-write for .gsd/.planning readers. Refs gsd-build/get-shit-done#2341 * fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback) - Recognize in-flight 🟡 milestone bullets like 🚧. - Derive from last **vX.Y Title** before ## Phases when emoji absent. - Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y in cleaned text instead of first (avoids v1.0 from shipped list). - Fixes init.execute-phase milestone_version and buildStateFrontmatter after state.begin-phase (syncStateFrontmatter). * feat(sdk): phase list, plan task structure, requirements extract handlers - Register phase.list-plans, phase.list-artifacts, plan.task-structure, requirements.extract-from-plans (SDK-only; golden-policy exceptions). - Add unit tests; document in QUERY-HANDLERS.md. - writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored. * feat(sdk): centralize getGsdAgentsDir in query helpers Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary ~/.claude/agents, legacy path). Use from init and docs-init init bundles. docs(15): add 15-CONTEXT for autonomous phase-15 run. * feat(sdk): query CLI CJS fallback and session correlation - createRegistry(eventStream, sessionId) threads correlation into mutation events - gsd-sdk query falls back to gsd-tools.cjs when no native handler matches (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings - Export createRegistry from @gsd-build/sdk; add sdk/README.md - Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId - Agents: prefer node dist/cli.js query over cat/grep for STATE and plans * fix(sdk): init phase_found parity, docs-init agents path, state field extract - Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal) - docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled - state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard) - Tests: commit_docs default true; config-get golden uses temp config; golden integration green Refs: #2302 * refactor(sdk): share SessionJsonlRecord in profile-extract-messages CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages. * fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify) - Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command - plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment - check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default - state.validate/sync: phaseTokenMatches + comparePhaseNum ordering - verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter - audit-open: has_scan_errors, unreadable rows, human report when scans fail - requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note - ingest-docs: repo-root path containment; classifier output slug-hash Golden parity test strips has_scan_errors until CJS adds field. * fix: Resolve CodeRabbit security and quality findings - Secure intel.ts and cli.ts against path traversal - Catch and validate git add status in commit.ts - Expand roadmap milestone marker extraction - Fix parsing array-of-objects in frontmatter YAML - Fix unhandled config evaluations - Improve coverage test parity mapping * test: raise planner character extraction limit to 48K * fix(sdk): resolve TS build error in docs-init passing config
1127 lines
41 KiB
TypeScript
1127 lines
41 KiB
TypeScript
/**
|
|
* Unit tests for phase lifecycle handlers.
|
|
*
|
|
* Tests phaseAdd, phaseAddBatch, phaseInsert, phaseScaffold, replaceInCurrentMilestone,
|
|
* and readModifyWriteRoadmapMd.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtemp, writeFile, readFile, rm, mkdir, readdir } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { existsSync } from 'node:fs';
|
|
|
|
// ─── Fixtures ─────────────────────────────────────────────────────────────
|
|
|
|
const MINIMAL_ROADMAP = `# Roadmap
|
|
|
|
## Current Milestone: v3.0 SDK-First Migration
|
|
|
|
### Phase 9: Foundation
|
|
|
|
**Goal:** Build foundation
|
|
**Requirements**: TBD
|
|
**Depends on:** Phase 8
|
|
**Plans:** 3 plans
|
|
|
|
Plans:
|
|
- [x] 09-01 (Foundation setup)
|
|
|
|
### Phase 10: Read-Only Queries
|
|
|
|
**Goal:** Port queries.
|
|
**Requirements**: TBD
|
|
**Depends on:** Phase 9
|
|
**Plans:** 3 plans
|
|
|
|
Plans:
|
|
- [x] 10-01 (Query setup)
|
|
|
|
---
|
|
*Last updated: 2026-04-08*
|
|
`;
|
|
|
|
const ROADMAP_WITH_DETAILS = `# Roadmap
|
|
|
|
<details>
|
|
<summary>v1.0 (shipped)</summary>
|
|
|
|
### Phase 1: Old Phase
|
|
|
|
**Goal:** Shipped already
|
|
**Plans:** 2 plans
|
|
|
|
</details>
|
|
|
|
## Current Milestone: v3.0 SDK-First Migration
|
|
|
|
### Phase 9: Foundation
|
|
|
|
**Goal:** Build foundation
|
|
**Requirements**: TBD
|
|
**Plans:** 3 plans
|
|
|
|
### Phase 10: Read-Only Queries
|
|
|
|
**Goal:** Port queries.
|
|
**Requirements**: TBD
|
|
**Plans:** 3 plans
|
|
|
|
---
|
|
*Last updated: 2026-04-08*
|
|
`;
|
|
|
|
const MINIMAL_STATE = `---
|
|
gsd_state_version: 1.0
|
|
milestone: v3.0
|
|
milestone_name: SDK-First Migration
|
|
status: executing
|
|
---
|
|
|
|
# Project State
|
|
|
|
## Current Position
|
|
|
|
Phase: 10 (Read-Only Queries) — EXECUTING
|
|
Plan: 2 of 3
|
|
Status: Executing Phase 10
|
|
|
|
## Session Continuity
|
|
|
|
Last session: 2026-04-07T10:00:00.000Z
|
|
Stopped at: Completed 10-02-PLAN.md
|
|
`;
|
|
|
|
/** Create a test project with .planning structure. */
|
|
async function setupTestProject(
|
|
tmpDir: string,
|
|
opts?: { roadmap?: string; state?: string; config?: Record<string, unknown>; phases?: string[] }
|
|
): Promise<string> {
|
|
const planningDir = join(tmpDir, '.planning');
|
|
await mkdir(planningDir, { recursive: true });
|
|
const phasesDir = join(planningDir, 'phases');
|
|
await mkdir(phasesDir, { recursive: true });
|
|
await writeFile(join(planningDir, 'ROADMAP.md'), opts?.roadmap || MINIMAL_ROADMAP, 'utf-8');
|
|
await writeFile(join(planningDir, 'STATE.md'), opts?.state || MINIMAL_STATE, 'utf-8');
|
|
await writeFile(
|
|
join(planningDir, 'config.json'),
|
|
JSON.stringify(opts?.config || { model_profile: 'balanced', phase_naming: 'sequential' }),
|
|
'utf-8'
|
|
);
|
|
// Create phase directories if requested
|
|
if (opts?.phases) {
|
|
for (const phase of opts.phases) {
|
|
await mkdir(join(phasesDir, phase), { recursive: true });
|
|
await writeFile(join(phasesDir, phase, '.gitkeep'), '', 'utf-8');
|
|
}
|
|
}
|
|
return tmpDir;
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-lifecycle-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── replaceInCurrentMilestone ──────────────────────────────────────────
|
|
|
|
describe('replaceInCurrentMilestone', () => {
|
|
it('replaces in full content when no details blocks', async () => {
|
|
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
|
|
const content = '### Phase 9: Foundation\n**Plans:** 3 plans\n';
|
|
const result = replaceInCurrentMilestone(content, /3 plans/, '4 plans');
|
|
expect(result).toContain('4 plans');
|
|
});
|
|
|
|
it('only replaces after last </details> block', async () => {
|
|
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
|
|
const content = '<details>\n### Phase 1: Old\n**Plans:** 3 plans\n</details>\n\n### Phase 9: Current\n**Plans:** 3 plans\n';
|
|
const result = replaceInCurrentMilestone(content, /3 plans/, '4 plans');
|
|
// Should only replace in the current milestone section (after </details>)
|
|
const before = result.slice(0, result.indexOf('</details>') + '</details>'.length);
|
|
const after = result.slice(result.indexOf('</details>') + '</details>'.length);
|
|
expect(before).toContain('3 plans'); // old milestone untouched
|
|
expect(after).toContain('4 plans'); // current milestone updated
|
|
});
|
|
});
|
|
|
|
// ─── readModifyWriteRoadmapMd ───────────────────────────────────────────
|
|
|
|
describe('readModifyWriteRoadmapMd', () => {
|
|
it('reads, modifies, and writes ROADMAP.md atomically', async () => {
|
|
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
const result = await readModifyWriteRoadmapMd(tmpDir, (content) => {
|
|
return content.replace('Port queries.', 'Port all queries.');
|
|
});
|
|
expect(result).toContain('Port all queries.');
|
|
const ondisk = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
expect(ondisk).toContain('Port all queries.');
|
|
});
|
|
|
|
it('creates and releases lockfile', async () => {
|
|
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
await readModifyWriteRoadmapMd(tmpDir, (c) => c);
|
|
// Lock should be released after operation
|
|
const lockPath = join(tmpDir, '.planning', 'ROADMAP.md.lock');
|
|
expect(existsSync(lockPath)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── phaseAdd ──────────────────────────────────────────────────────────
|
|
|
|
describe('phaseAdd', () => {
|
|
it('creates directory and updates ROADMAP.md for sequential phase', async () => {
|
|
const { phaseAdd } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation', '10-read-only-queries'],
|
|
});
|
|
|
|
const result = await phaseAdd(['New Feature'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.phase_number).toBe(11);
|
|
expect(data.padded).toBe('11');
|
|
expect(data.name).toBe('New Feature');
|
|
expect(data.slug).toBe('new-feature');
|
|
expect(data.naming_mode).toBe('sequential');
|
|
|
|
// Verify directory was created
|
|
const dir = data.directory as string;
|
|
expect(dir).toContain('11-new-feature');
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
const entries = await readdir(phasesDir, { withFileTypes: true });
|
|
const newDir = entries.find(e => e.isDirectory() && e.name.includes('11-new-feature'));
|
|
expect(newDir).toBeTruthy();
|
|
|
|
// Verify .gitkeep
|
|
expect(existsSync(join(phasesDir, newDir!.name, '.gitkeep'))).toBe(true);
|
|
|
|
// Verify ROADMAP.md updated
|
|
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
expect(roadmap).toContain('### Phase 11: New Feature');
|
|
expect(roadmap).toContain('**Goal:** [To be planned]');
|
|
});
|
|
|
|
it('skips phases >= 999 when calculating next number (backlog exclusion)', async () => {
|
|
const { phaseAdd } = await import('./phase-lifecycle.js');
|
|
const roadmapWith999 = MINIMAL_ROADMAP.replace(
|
|
'---\n*Last updated',
|
|
'### Phase 999: Backlog\n\n**Goal:** Backlog items\n**Plans:** 0 plans\n\n---\n*Last updated'
|
|
);
|
|
await setupTestProject(tmpDir, { roadmap: roadmapWith999 });
|
|
|
|
const result = await phaseAdd(['After Ten'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
// Should be 11, not 1000
|
|
expect(data.phase_number).toBe(11);
|
|
});
|
|
|
|
it('throws GSDError with Validation for empty description', async () => {
|
|
const { phaseAdd } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
|
|
await expect(phaseAdd([], tmpDir)).rejects.toThrow('description required');
|
|
});
|
|
|
|
it('inserts phase entry before last --- separator', async () => {
|
|
const { phaseAdd } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
|
|
await phaseAdd(['Inserted Phase'], tmpDir);
|
|
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
|
|
// The new phase should appear before the trailing ---
|
|
const phaseIdx = roadmap.indexOf('### Phase 11: Inserted Phase');
|
|
const sepIdx = roadmap.lastIndexOf('\n---');
|
|
expect(phaseIdx).toBeLessThan(sepIdx);
|
|
expect(phaseIdx).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ─── phaseAddBatch ─────────────────────────────────────────────────────
|
|
|
|
describe('phaseAddBatch', () => {
|
|
it('adds multiple sequential phases in one pass', async () => {
|
|
const { phaseAddBatch } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation', '10-read-only-queries'],
|
|
});
|
|
|
|
const result = await phaseAddBatch(['Alpha', 'Beta'], tmpDir);
|
|
const data = result.data as { phases: Array<Record<string, unknown>>; count: number };
|
|
|
|
expect(data.count).toBe(2);
|
|
expect(data.phases[0].phase_number).toBe(11);
|
|
expect(data.phases[0].name).toBe('Alpha');
|
|
expect(data.phases[1].phase_number).toBe(12);
|
|
expect(data.phases[1].name).toBe('Beta');
|
|
|
|
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
expect(roadmap).toContain('### Phase 11: Alpha');
|
|
expect(roadmap).toContain('### Phase 12: Beta');
|
|
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
expect(existsSync(join(phasesDir, '11-alpha', '.gitkeep'))).toBe(true);
|
|
expect(existsSync(join(phasesDir, '12-beta', '.gitkeep'))).toBe(true);
|
|
});
|
|
|
|
it('accepts --descriptions JSON array', async () => {
|
|
const { phaseAddBatch } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, { phases: ['09-foundation', '10-read-only-queries'] });
|
|
|
|
const result = await phaseAddBatch(
|
|
['--descriptions', JSON.stringify(['One', 'Two'])],
|
|
tmpDir,
|
|
);
|
|
const data = result.data as { count: number };
|
|
expect(data.count).toBe(2);
|
|
});
|
|
|
|
it('throws when no descriptions', async () => {
|
|
const { phaseAddBatch } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
|
|
await expect(phaseAddBatch([], tmpDir)).rejects.toThrow('descriptions array required');
|
|
});
|
|
});
|
|
|
|
// ─── phaseInsert ────────────────────────────────────────────────────────
|
|
|
|
describe('phaseInsert', () => {
|
|
it('creates decimal phase directory after target phase', async () => {
|
|
const { phaseInsert } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation', '10-read-only-queries'],
|
|
});
|
|
|
|
const result = await phaseInsert(['10', 'Urgent Fix'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.phase_number).toBe('10.1');
|
|
expect(data.after_phase).toBe('10');
|
|
expect(data.name).toBe('Urgent Fix');
|
|
expect(data.slug).toBe('urgent-fix');
|
|
|
|
// Verify directory created
|
|
const dir = data.directory as string;
|
|
expect(dir).toContain('10.1-urgent-fix');
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
const entries = await readdir(phasesDir, { withFileTypes: true });
|
|
const newDir = entries.find(e => e.isDirectory() && e.name.includes('10.1-urgent-fix'));
|
|
expect(newDir).toBeTruthy();
|
|
});
|
|
|
|
it('scans both directories and ROADMAP.md for existing decimals to avoid collisions', async () => {
|
|
const { phaseInsert } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation', '10-read-only-queries', '10.1-hotfix'],
|
|
});
|
|
|
|
const result = await phaseInsert(['10', 'Another Fix'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
// Should be 10.2 since 10.1 already exists on disk
|
|
expect(data.phase_number).toBe('10.2');
|
|
});
|
|
|
|
it('inserts section in ROADMAP.md after target phase', async () => {
|
|
const { phaseInsert } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
|
|
await phaseInsert(['10', 'Urgent Fix'], tmpDir);
|
|
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
|
|
expect(roadmap).toContain('### Phase 10.1: Urgent Fix (INSERTED)');
|
|
// Should appear after Phase 10 section
|
|
const phase10Idx = roadmap.indexOf('### Phase 10:');
|
|
const insertedIdx = roadmap.indexOf('### Phase 10.1:');
|
|
expect(insertedIdx).toBeGreaterThan(phase10Idx);
|
|
});
|
|
|
|
it('throws GSDError for missing target phase', async () => {
|
|
const { phaseInsert } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
|
|
await expect(phaseInsert(['99', 'Missing'], tmpDir)).rejects.toThrow('Phase 99 not found');
|
|
});
|
|
|
|
it('throws GSDError with Validation for missing args', async () => {
|
|
const { phaseInsert } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
|
|
await expect(phaseInsert([], tmpDir)).rejects.toThrow('after-phase and description required');
|
|
});
|
|
});
|
|
|
|
// ─── phaseScaffold ──────────────────────────────────────────────────────
|
|
|
|
describe('phaseScaffold', () => {
|
|
it('creates context template for a phase', async () => {
|
|
const { phaseScaffold } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation'],
|
|
});
|
|
|
|
const result = await phaseScaffold(['context', '9'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.created).toBe(true);
|
|
const filePath = data.path as string;
|
|
expect(filePath).toContain('09-CONTEXT.md');
|
|
|
|
// Check content
|
|
const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-CONTEXT.md');
|
|
expect(existsSync(fullPath)).toBe(true);
|
|
const content = await readFile(fullPath, 'utf-8');
|
|
expect(content).toContain('phase: "09"');
|
|
expect(content).toContain('Context');
|
|
});
|
|
|
|
it('creates uat template', async () => {
|
|
const { phaseScaffold } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation'],
|
|
});
|
|
|
|
const result = await phaseScaffold(['uat', '9'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.created).toBe(true);
|
|
const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-UAT.md');
|
|
expect(existsSync(fullPath)).toBe(true);
|
|
const content = await readFile(fullPath, 'utf-8');
|
|
expect(content).toContain('User Acceptance Testing');
|
|
});
|
|
|
|
it('creates verification template', async () => {
|
|
const { phaseScaffold } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation'],
|
|
});
|
|
|
|
const result = await phaseScaffold(['verification', '9'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.created).toBe(true);
|
|
const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-VERIFICATION.md');
|
|
expect(existsSync(fullPath)).toBe(true);
|
|
const content = await readFile(fullPath, 'utf-8');
|
|
expect(content).toContain('Verification');
|
|
});
|
|
|
|
it('creates phase-dir under phases/', async () => {
|
|
const { phaseScaffold } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir);
|
|
|
|
const result = await phaseScaffold(['phase-dir', '15', 'New Module'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.created).toBe(true);
|
|
const dir = data.directory as string;
|
|
expect(dir).toContain('15-new-module');
|
|
});
|
|
|
|
it('returns already_exists for existing file', async () => {
|
|
const { phaseScaffold } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation'],
|
|
});
|
|
|
|
// Create first
|
|
await phaseScaffold(['context', '9'], tmpDir);
|
|
// Second call should return already_exists
|
|
const result = await phaseScaffold(['context', '9'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data.created).toBe(false);
|
|
expect(data.reason).toBe('already_exists');
|
|
});
|
|
|
|
it('throws GSDError for unknown type', async () => {
|
|
const { phaseScaffold } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation'],
|
|
});
|
|
|
|
await expect(phaseScaffold(['badtype', '9'], tmpDir)).rejects.toThrow('Unknown scaffold type');
|
|
});
|
|
});
|
|
|
|
// ─── phaseRemove ─────────────────────────────────────────────────────────
|
|
|
|
const ROADMAP_FOR_REMOVE = `# Roadmap
|
|
|
|
## Current Milestone: v3.0 SDK-First Migration
|
|
|
|
### Phase 5: Auth
|
|
|
|
**Goal:** Build authentication
|
|
**Requirements**: TBD
|
|
**Depends on:** Phase 4
|
|
**Plans:** 2 plans
|
|
|
|
Plans:
|
|
- [x] 05-01 (Auth setup)
|
|
- [x] 05-02 (Auth complete)
|
|
|
|
### Phase 6: Dashboard
|
|
|
|
**Goal:** Build dashboard
|
|
**Requirements**: TBD
|
|
**Depends on:** Phase 5
|
|
**Plans:** 3 plans
|
|
|
|
Plans:
|
|
- [ ] 06-01 (Dashboard setup)
|
|
|
|
### Phase 7: API
|
|
|
|
**Goal:** Build API layer
|
|
**Requirements**: TBD
|
|
**Depends on:** Phase 6
|
|
**Plans:** 2 plans
|
|
|
|
Plans:
|
|
- [ ] 07-01 (API setup)
|
|
|
|
---
|
|
*Last updated: 2026-04-08*
|
|
`;
|
|
|
|
const STATE_FOR_REMOVE = `---
|
|
gsd_state_version: 1.0
|
|
milestone: v3.0
|
|
milestone_name: SDK-First Migration
|
|
status: executing
|
|
progress:
|
|
total_phases: 7
|
|
completed_phases: 4
|
|
total_plans: 15
|
|
completed_plans: 12
|
|
percent: 80
|
|
---
|
|
|
|
# Project State
|
|
|
|
## Current Position
|
|
|
|
Phase: 6 (Dashboard) — EXECUTING
|
|
Plan: 1 of 3
|
|
Status: Executing Phase 6
|
|
|
|
## Session Continuity
|
|
|
|
Last session: 2026-04-08T10:00:00.000Z
|
|
Stopped at: Started
|
|
`;
|
|
|
|
describe('phaseRemove', () => {
|
|
it('removes integer phase directory and renumbers subsequent phases', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_REMOVE,
|
|
state: STATE_FOR_REMOVE,
|
|
phases: ['05-auth', '06-dashboard', '07-api'],
|
|
});
|
|
// Create files inside directories to verify file renaming
|
|
await writeFile(join(phasesDir, '06-dashboard', '06-01-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(phasesDir, '07-api', '07-01-PLAN.md'), 'plan', 'utf-8');
|
|
|
|
const result = await phaseRemove(['6'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.removed).toBe('6');
|
|
expect(data.directory_deleted).toBeTruthy();
|
|
expect(data.roadmap_updated).toBe(true);
|
|
expect(data.state_updated).toBe(true);
|
|
|
|
// Phase 6 dir should be gone
|
|
const entries = await readdir(phasesDir, { withFileTypes: true });
|
|
const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
expect(dirNames.find(d => d.includes('06-dashboard'))).toBeUndefined();
|
|
|
|
// Phase 7 should have been renamed to 06
|
|
const renamedDir = dirNames.find(d => d.includes('06-api'));
|
|
expect(renamedDir).toBeTruthy();
|
|
|
|
// Files inside renamed dir should also be renamed
|
|
const files = await readdir(join(phasesDir, renamedDir!));
|
|
expect(files.some(f => f.includes('06-01'))).toBe(true);
|
|
expect(files.some(f => f.includes('07-01'))).toBe(false);
|
|
});
|
|
|
|
it('removes decimal phase and renumbers sibling decimals', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
const decimalRoadmap = ROADMAP_FOR_REMOVE.replace(
|
|
'### Phase 7: API',
|
|
'### Phase 6.1: Hotfix A\n\n**Goal:** Fix A\n**Plans:** 1 plans\n\n### Phase 6.2: Hotfix B\n\n**Goal:** Fix B\n**Plans:** 1 plans\n\n### Phase 6.3: Hotfix C\n\n**Goal:** Fix C\n**Plans:** 1 plans\n\n### Phase 7: API'
|
|
);
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: decimalRoadmap,
|
|
state: STATE_FOR_REMOVE,
|
|
phases: ['05-auth', '06-dashboard', '06.1-hotfix-a', '06.2-hotfix-b', '06.3-hotfix-c', '07-api'],
|
|
});
|
|
// Create files with phase ID in name
|
|
await writeFile(join(phasesDir, '06.2-hotfix-b', '06.2-01-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(phasesDir, '06.3-hotfix-c', '06.3-01-PLAN.md'), 'plan', 'utf-8');
|
|
|
|
const result = await phaseRemove(['6.1'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.removed).toBe('6.1');
|
|
|
|
// 06.1 should be gone
|
|
const entries = await readdir(phasesDir, { withFileTypes: true });
|
|
const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
expect(dirNames.find(d => d.includes('06.1-hotfix-a'))).toBeUndefined();
|
|
|
|
// 06.2 should become 06.1, 06.3 should become 06.2
|
|
expect(dirNames.find(d => d.includes('06.1-hotfix-b'))).toBeTruthy();
|
|
expect(dirNames.find(d => d.includes('06.2-hotfix-c'))).toBeTruthy();
|
|
expect(dirNames.find(d => d.includes('06.3'))).toBeUndefined();
|
|
|
|
// Files inside renamed dirs should be renamed
|
|
const dir1Files = await readdir(join(phasesDir, '06.1-hotfix-b'));
|
|
expect(dir1Files.some(f => f.includes('06.1-01'))).toBe(true);
|
|
const dir2Files = await readdir(join(phasesDir, '06.2-hotfix-c'));
|
|
expect(dir2Files.some(f => f.includes('06.2-01'))).toBe(true);
|
|
});
|
|
|
|
it('requires --force to remove phase with SUMMARY files', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_REMOVE,
|
|
state: STATE_FOR_REMOVE,
|
|
phases: ['05-auth', '06-dashboard', '07-api'],
|
|
});
|
|
// Create a SUMMARY file to simulate executed work
|
|
await writeFile(join(phasesDir, '06-dashboard', '06-01-SUMMARY.md'), 'summary', 'utf-8');
|
|
|
|
await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('--force');
|
|
});
|
|
|
|
it('allows removal with --force even when SUMMARY files exist', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_REMOVE,
|
|
state: STATE_FOR_REMOVE,
|
|
phases: ['05-auth', '06-dashboard', '07-api'],
|
|
});
|
|
await writeFile(join(phasesDir, '06-dashboard', '06-01-SUMMARY.md'), 'summary', 'utf-8');
|
|
|
|
const result = await phaseRemove(['6', '--force'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data.removed).toBe('6');
|
|
expect(data.directory_deleted).toBeTruthy();
|
|
});
|
|
|
|
it('throws GSDError when ROADMAP.md is missing', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
// Set up without ROADMAP.md
|
|
const planningDir = join(tmpDir, '.planning');
|
|
await mkdir(planningDir, { recursive: true });
|
|
const phasesDir = join(planningDir, 'phases');
|
|
await mkdir(phasesDir, { recursive: true });
|
|
await writeFile(join(planningDir, 'STATE.md'), STATE_FOR_REMOVE, 'utf-8');
|
|
|
|
await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('ROADMAP.md not found');
|
|
});
|
|
|
|
it('throws GSDError when phase number is missing', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_REMOVE,
|
|
state: STATE_FOR_REMOVE,
|
|
});
|
|
|
|
await expect(phaseRemove([], tmpDir)).rejects.toThrow('phase number required');
|
|
});
|
|
|
|
it('updates ROADMAP.md by removing phase section and renumbering', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_REMOVE,
|
|
state: STATE_FOR_REMOVE,
|
|
phases: ['05-auth', '06-dashboard', '07-api'],
|
|
});
|
|
|
|
await phaseRemove(['6'], tmpDir);
|
|
|
|
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
// Phase 6 section should be removed
|
|
expect(roadmap).not.toContain('### Phase 6: Dashboard');
|
|
// Phase 7 should be renumbered to 6
|
|
expect(roadmap).toContain('### Phase 6: API');
|
|
// Plan references should be renumbered
|
|
expect(roadmap).toContain('06-01');
|
|
expect(roadmap).not.toContain('07-01');
|
|
});
|
|
|
|
it('decrements total_phases in STATE.md frontmatter', async () => {
|
|
const { phaseRemove } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_REMOVE,
|
|
state: STATE_FOR_REMOVE,
|
|
phases: ['05-auth', '06-dashboard', '07-api'],
|
|
});
|
|
|
|
await phaseRemove(['6'], tmpDir);
|
|
|
|
const stateContent = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
// total_phases should be decremented from 7 to 6
|
|
expect(stateContent).toMatch(/total_phases:\s*6/);
|
|
});
|
|
});
|
|
|
|
// ─── phaseComplete ─────────────────────────────────────────────────────────
|
|
|
|
const ROADMAP_FOR_COMPLETE = `# Roadmap
|
|
|
|
<details>
|
|
<summary>v1.0 (shipped)</summary>
|
|
|
|
### Phase 1: Old Phase
|
|
|
|
**Goal:** Shipped already
|
|
**Plans:** 2 plans
|
|
|
|
</details>
|
|
|
|
## Current Milestone: v3.0 SDK-First Migration
|
|
|
|
| Phase | Plans | Status | Completed |
|
|
|-------|-------|--------|-----------|
|
|
| 9. | 3/3 | Complete | 2026-04-01 |
|
|
| 10. | 0/3 | In Progress | |
|
|
| 11. | 0/2 | Not Started | |
|
|
|
|
- [x] Phase 9: Foundation (completed 2026-04-01)
|
|
- [ ] Phase 10: Read-Only Queries
|
|
- [ ] Phase 11: Final Phase
|
|
|
|
### Phase 9: Foundation
|
|
|
|
**Goal:** Build foundation
|
|
**Requirements**: FOUND-01, FOUND-02
|
|
**Depends on:** Phase 8
|
|
**Plans:** 3/3 plans complete
|
|
|
|
Plans:
|
|
- [x] 09-01 (Foundation setup)
|
|
- [x] 09-02 (Foundation core)
|
|
- [x] 09-03 (Foundation tests)
|
|
|
|
### Phase 10: Read-Only Queries
|
|
|
|
**Goal:** Port queries
|
|
**Requirements**: QUERY-01
|
|
**Depends on:** Phase 9
|
|
**Plans:** 3 plans
|
|
|
|
Plans:
|
|
- [x] 10-01 (Query setup)
|
|
- [x] 10-02 (Query core)
|
|
- [ ] 10-03 (Query tests)
|
|
|
|
### Phase 11: Final Phase
|
|
|
|
**Goal:** Final work
|
|
**Requirements**: FINAL-01
|
|
**Depends on:** Phase 10
|
|
**Plans:** 2 plans
|
|
|
|
Plans:
|
|
- [ ] 11-01 (Final setup)
|
|
- [ ] 11-02 (Final complete)
|
|
|
|
---
|
|
*Last updated: 2026-04-08*
|
|
`;
|
|
|
|
const STATE_FOR_COMPLETE = `---
|
|
gsd_state_version: 1.0
|
|
milestone: v3.0
|
|
milestone_name: SDK-First Migration
|
|
status: executing
|
|
progress:
|
|
total_phases: 3
|
|
completed_phases: 1
|
|
total_plans: 8
|
|
completed_plans: 5
|
|
percent: 33
|
|
---
|
|
|
|
# Project State
|
|
|
|
## Current Position
|
|
|
|
Phase: 10 of 3 (Read-Only Queries) — EXECUTING
|
|
Plan: 3 of 3
|
|
Status: Executing Phase 10
|
|
Last activity: 2026-04-08
|
|
|
|
## Performance Metrics
|
|
|
|
**Velocity:**
|
|
|
|
- Total plans completed: 3
|
|
- Average duration: --
|
|
- Total execution time: 0 hours
|
|
|
|
**By Phase:**
|
|
|
|
| Phase | Plans | Total | Avg/Plan |
|
|
|-------|-------|-------|----------|
|
|
| 9 | 3 | - | - |
|
|
|
|
## Session Continuity
|
|
|
|
Last session: 2026-04-08T10:00:00.000Z
|
|
Stopped at: Completed 10-03-PLAN.md
|
|
`;
|
|
|
|
const REQUIREMENTS_FOR_COMPLETE = `# Requirements
|
|
|
|
## Checklist
|
|
|
|
- [x] **FOUND-01** Foundation setup
|
|
- [x] **FOUND-02** Foundation core
|
|
- [ ] **QUERY-01** Query implementation
|
|
- [ ] **FINAL-01** Final work
|
|
|
|
## Traceability
|
|
|
|
| Requirement | Phase | Status |
|
|
|-------------|-------|--------|
|
|
| FOUND-01 | Phase 9 | Complete |
|
|
| FOUND-02 | Phase 9 | Complete |
|
|
| QUERY-01 | Phase 10 | In Progress |
|
|
| FINAL-01 | Phase 11 | Pending |
|
|
`;
|
|
|
|
describe('phaseComplete', () => {
|
|
it('marks phase checkbox, updates progress table, and plan count in ROADMAP.md', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
|
|
});
|
|
// Create PLAN and SUMMARY files for phase 10
|
|
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
|
|
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan1', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan2', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan3', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary1', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary2', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary3', 'utf-8');
|
|
// Create REQUIREMENTS.md
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
const result = await phaseComplete(['10'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.completed_phase).toBe('10');
|
|
expect(data.plans_executed).toBe('3/3');
|
|
expect(data.is_last_phase).toBe(false);
|
|
expect(data.next_phase).toBeTruthy();
|
|
expect(data.roadmap_updated).toBe(true);
|
|
|
|
// Check ROADMAP.md updates
|
|
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
// Checkbox should be marked
|
|
expect(roadmap).toMatch(/\[x\].*Phase 10/);
|
|
// Progress table should show Complete
|
|
expect(roadmap).toMatch(/10\.?\s*\|.*3\/3.*\|.*Complete/i);
|
|
// Plan count in section should be updated
|
|
expect(roadmap).toContain('3/3 plans complete');
|
|
// Plan checkboxes should be [x]
|
|
expect(roadmap).toMatch(/\[x\] 10-01/);
|
|
expect(roadmap).toMatch(/\[x\] 10-02/);
|
|
expect(roadmap).toMatch(/\[x\] 10-03/);
|
|
});
|
|
|
|
it('updates REQUIREMENTS.md checkboxes and traceability table', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
|
|
});
|
|
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
|
|
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan1', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan2', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan3', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary1', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary2', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary3', 'utf-8');
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
await phaseComplete(['10'], tmpDir);
|
|
|
|
const req = await readFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
|
// QUERY-01 checkbox should be marked
|
|
expect(req).toMatch(/\[x\].*\*\*QUERY-01\*\*/);
|
|
// Traceability should show Complete for QUERY-01
|
|
expect(req).toMatch(/QUERY-01\s*\|.*\|\s*Complete\s*\|/);
|
|
// FINAL-01 should remain Pending
|
|
expect(req).toMatch(/FINAL-01\s*\|.*\|\s*Pending\s*\|/);
|
|
});
|
|
|
|
it('updates STATE.md fields: current phase, status, completed phases, percent', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
|
|
});
|
|
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
|
|
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
await phaseComplete(['10'], tmpDir);
|
|
|
|
const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
// Phase should advance to 11
|
|
expect(state).toMatch(/Phase:\s*11/);
|
|
// Status should indicate ready to plan
|
|
expect(state).toMatch(/Status:\s*Ready to plan/);
|
|
// Completed phases should be incremented from 1 to 2
|
|
expect(state).toMatch(/completed_phases:\s*2/);
|
|
// Percent should be recalculated (2/3 = 67%)
|
|
expect(state).toMatch(/percent:\s*67/);
|
|
});
|
|
|
|
it('detects next phase from filesystem, falls back to ROADMAP.md', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
|
|
});
|
|
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
|
|
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
const result = await phaseComplete(['10'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
// Next phase should be 11 (from filesystem)
|
|
expect(data.next_phase).toBe('11');
|
|
expect(data.is_last_phase).toBe(false);
|
|
});
|
|
|
|
it('sets is_last_phase when completing the final phase', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
|
|
});
|
|
const p11Dir = join(tmpDir, '.planning', 'phases', '11-final-phase');
|
|
await writeFile(join(p11Dir, '11-01-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p11Dir, '11-01-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
const result = await phaseComplete(['11'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.is_last_phase).toBe(true);
|
|
expect(data.next_phase).toBeNull();
|
|
|
|
// State should show milestone complete
|
|
const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
expect(state).toMatch(/Status:\s*Milestone complete/);
|
|
});
|
|
|
|
it('collects UAT/VERIFICATION warnings without blocking', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
|
|
});
|
|
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
|
|
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
|
|
// Create UAT file with pending status
|
|
await writeFile(join(p10Dir, '10-UAT.md'), '---\nresult: pending\n---\nPending tests', 'utf-8');
|
|
// Create VERIFICATION file with gaps
|
|
await writeFile(join(p10Dir, '10-VERIFICATION.md'), '---\nstatus: gaps_found\n---\nGaps', 'utf-8');
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
const result = await phaseComplete(['10'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
// Should complete despite warnings
|
|
expect(data.completed_phase).toBe('10');
|
|
expect(data.has_warnings).toBe(true);
|
|
const warnings = data.warnings as string[];
|
|
expect(warnings.length).toBeGreaterThan(0);
|
|
expect(warnings.some(w => w.includes('pending'))).toBe(true);
|
|
expect(warnings.some(w => w.includes('gaps'))).toBe(true);
|
|
});
|
|
|
|
it('throws GSDError for missing phase', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation'],
|
|
});
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
await expect(phaseComplete(['99'], tmpDir)).rejects.toThrow('Phase 99 not found');
|
|
});
|
|
|
|
it('updates performance metrics table in STATE.md', async () => {
|
|
const { phaseComplete } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
roadmap: ROADMAP_FOR_COMPLETE,
|
|
state: STATE_FOR_COMPLETE,
|
|
phases: ['09-foundation', '10-read-only-queries', '11-final-phase'],
|
|
});
|
|
const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries');
|
|
await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary', 'utf-8');
|
|
await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8');
|
|
|
|
await phaseComplete(['10'], tmpDir);
|
|
|
|
const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
// Total plans completed should be incremented: 3 + 3 = 6
|
|
expect(state).toContain('Total plans completed: 6');
|
|
// By Phase table should have a row for phase 10
|
|
expect(state).toMatch(/\|\s*10\s*\|\s*3\s*\|/);
|
|
});
|
|
});
|
|
|
|
// ─── phasesClear ────────────────────────────────────────────────────────────
|
|
|
|
describe('phasesClear', () => {
|
|
it('throws GSDError without --confirm flag, showing count', async () => {
|
|
const { phasesClear } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation', '10-read-only-queries', '999.1-backlog'],
|
|
});
|
|
|
|
// Should throw with count of dirs to delete (2, not 3 since 999.1 is excluded)
|
|
await expect(phasesClear([], tmpDir)).rejects.toThrow(/2 phase director/);
|
|
});
|
|
|
|
it('deletes all dirs except 999.x with --confirm', async () => {
|
|
const { phasesClear } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation', '10-read-only-queries', '999.1-backlog'],
|
|
});
|
|
|
|
const result = await phasesClear(['--confirm'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.cleared).toBe(2);
|
|
|
|
// Verify filesystem
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
const entries = await readdir(phasesDir, { withFileTypes: true });
|
|
const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
expect(dirNames.length).toBe(1);
|
|
expect(dirNames[0]).toContain('999');
|
|
});
|
|
|
|
it('returns 0 cleared when phases dir is empty', async () => {
|
|
const { phasesClear } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, { phases: [] });
|
|
|
|
const result = await phasesClear(['--confirm'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data.cleared).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── phasesArchive ──────────────────────────────────────────────────────────
|
|
|
|
describe('phasesArchive', () => {
|
|
it('moves milestone phase dirs to milestones/{version}-phases/', async () => {
|
|
const { phasesArchive } = await import('./phase-lifecycle.js');
|
|
await setupTestProject(tmpDir, {
|
|
phases: ['09-foundation', '10-read-only-queries'],
|
|
});
|
|
|
|
const result = await phasesArchive(['v3.0'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
expect(data.version).toBe('v3.0');
|
|
expect((data.archived as number)).toBeGreaterThan(0);
|
|
|
|
// Verify archive directory exists
|
|
const archiveDir = join(tmpDir, '.planning', 'milestones', 'v3.0-phases');
|
|
expect(existsSync(archiveDir)).toBe(true);
|
|
|
|
// Verify dirs were moved
|
|
const archivedEntries = await readdir(archiveDir, { withFileTypes: true });
|
|
const archivedDirs = archivedEntries.filter(e => e.isDirectory()).map(e => e.name);
|
|
expect(archivedDirs.length).toBeGreaterThan(0);
|
|
|
|
// Original dirs should be gone
|
|
const phasesDir = join(tmpDir, '.planning', 'phases');
|
|
const remaining = await readdir(phasesDir, { withFileTypes: true });
|
|
const remainingDirs = remaining.filter(e => e.isDirectory()).map(e => e.name);
|
|
expect(remainingDirs.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Registry integration ──────────────────────────────────────────────────
|
|
|
|
describe('lifecycle handlers in registry', () => {
|
|
it('registers all 7 lifecycle handlers with dot notation', async () => {
|
|
const { createRegistry } = await import('./index.js');
|
|
const registry = createRegistry();
|
|
|
|
const commands = [
|
|
'phase.add', 'phase.insert', 'phase.remove', 'phase.complete',
|
|
'phase.scaffold', 'phases.clear', 'phases.archive',
|
|
];
|
|
|
|
for (const cmd of commands) {
|
|
const handler = registry.getHandler(cmd);
|
|
expect(handler, `${cmd} should be registered`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('registers space-delimited aliases', async () => {
|
|
const { createRegistry } = await import('./index.js');
|
|
const registry = createRegistry();
|
|
|
|
const commands = [
|
|
'phase add', 'phase insert', 'phase remove', 'phase complete',
|
|
'phase scaffold', 'phases clear', 'phases archive',
|
|
];
|
|
|
|
for (const cmd of commands) {
|
|
const handler = registry.getHandler(cmd);
|
|
expect(handler, `${cmd} should be registered`).toBeDefined();
|
|
}
|
|
});
|
|
});
|