diff --git a/tests/verify-health.test.cjs b/tests/verify-health.test.cjs new file mode 100644 index 000000000..3bf48b68f --- /dev/null +++ b/tests/verify-health.test.cjs @@ -0,0 +1,529 @@ +/** + * GSD Tools Tests - Validate Health Command + * + * Comprehensive tests for validate-health covering all 8 health checks + * and the repair path. + */ + +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'); + +// ─── Helpers for setting up minimal valid projects ──────────────────────────── + +function writeMinimalRoadmap(tmpDir, phases = ['1']) { + const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n${lines}\n` + ); +} + +function writeMinimalProjectMd(tmpDir, sections = ['## What This Is', '## Core Value', '## Requirements']) { + const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + `# Project\n\n${content}` + ); +} + +function writeMinimalStateMd(tmpDir, content) { + const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + defaultContent + ); +} + +function writeValidConfigJson(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// validate health command — all 8 checks +// ───────────────────────────────────────────────────────────────────────────── + +describe('validate health command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // ─── Check 1: .planning/ exists ─────────────────────────────────────────── + + test("returns 'broken' when .planning directory is missing", () => { + // createTempProject creates .planning/phases — remove it entirely + fs.rmSync(path.join(tmpDir, '.planning'), { recursive: true, force: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.status, 'broken', 'should be broken'); + assert.ok( + output.errors.some(e => e.code === 'E001'), + `Expected E001 in errors: ${JSON.stringify(output.errors)}` + ); + }); + + // ─── Check 2: PROJECT.md exists and has required sections ───────────────── + + test('warns when PROJECT.md is missing', () => { + // No PROJECT.md in .planning + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + writeValidConfigJson(tmpDir); + // Create valid phase dir so no W007 + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.errors.some(e => e.code === 'E002'), + `Expected E002 in errors: ${JSON.stringify(output.errors)}` + ); + }); + + test('warns when PROJECT.md missing required sections', () => { + // PROJECT.md missing "## Core Value" section + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\n## What This Is\n\nFoo\n\n## Requirements\n\nBar\n' + ); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const w001s = output.warnings.filter(w => w.code === 'W001'); + assert.ok(w001s.length > 0, `Expected W001 warnings: ${JSON.stringify(output.warnings)}`); + assert.ok( + w001s.some(w => w.message.includes('## Core Value')), + `Expected W001 mentioning "## Core Value": ${JSON.stringify(w001s)}` + ); + }); + + test('passes when PROJECT.md has all required sections', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + !output.errors.some(e => e.code === 'E002'), + `Should not have E002: ${JSON.stringify(output.errors)}` + ); + assert.ok( + !output.warnings.some(w => w.code === 'W001'), + `Should not have W001: ${JSON.stringify(output.warnings)}` + ); + }); + + // ─── Check 3: ROADMAP.md exists ─────────────────────────────────────────── + + test('errors when ROADMAP.md is missing', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalStateMd(tmpDir); + writeValidConfigJson(tmpDir); + // No ROADMAP.md + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.errors.some(e => e.code === 'E003'), + `Expected E003 in errors: ${JSON.stringify(output.errors)}` + ); + }); + + // ─── Check 4: STATE.md exists and references valid phases ───────────────── + + test('errors when STATE.md is missing with repairable true', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + // No STATE.md + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const e004 = output.errors.find(e => e.code === 'E004'); + assert.ok(e004, `Expected E004 in errors: ${JSON.stringify(output.errors)}`); + assert.strictEqual(e004.repairable, true, 'E004 should be repairable'); + }); + + test('warns when STATE.md references nonexistent phase', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeValidConfigJson(tmpDir); + // STATE.md mentions Phase 99 but only 01-a dir exists + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\nPhase 99 is the current phase.\n' + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W002'), + `Expected W002 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + // ─── Check 5: config.json valid JSON + valid schema ─────────────────────── + + test('warns when config.json is missing with repairable true', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + // No config.json + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const w003 = output.warnings.find(w => w.code === 'W003'); + assert.ok(w003, `Expected W003 in warnings: ${JSON.stringify(output.warnings)}`); + assert.strictEqual(w003.repairable, true, 'W003 should be repairable'); + }); + + test('errors when config.json has invalid JSON', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + '{broken json' + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.errors.some(e => e.code === 'E005'), + `Expected E005 in errors: ${JSON.stringify(output.errors)}` + ); + }); + + test('warns when config.json has invalid model_profile', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'invalid' }) + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W004'), + `Expected W004 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + // ─── Check 6: Phase directory naming (NN-name format) ───────────────────── + + test('warns about incorrectly named phase directories', () => { + writeMinimalProjectMd(tmpDir); + // Roadmap with no phases to avoid W006 + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\nNo phases yet.\n' + ); + writeMinimalStateMd(tmpDir, '# Session State\n\nNo phase references.\n'); + writeValidConfigJson(tmpDir); + // Create a badly named dir + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'bad_name'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W005'), + `Expected W005 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + // ─── Check 7: Orphaned plans (PLAN without SUMMARY) ─────────────────────── + + test('reports orphaned plans (PLAN without SUMMARY) as info', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + writeValidConfigJson(tmpDir); + // Create 01-test phase dir with a PLAN but no matching SUMMARY + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + // No 01-01-SUMMARY.md + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.info.some(i => i.code === 'I001'), + `Expected I001 in info: ${JSON.stringify(output.info)}` + ); + }); + + // ─── Check 8: Consistency (roadmap/disk sync) ───────────────────────────── + + test('warns about phase in ROADMAP but not on disk', () => { + writeMinimalProjectMd(tmpDir); + // ROADMAP mentions Phase 5 but no 05-xxx dir + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 5: Future Phase\n' + ); + writeMinimalStateMd(tmpDir, '# Session State\n\nNo phase refs.\n'); + writeValidConfigJson(tmpDir); + // No phase dirs + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W006'), + `Expected W006 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + test('warns about phase on disk but not in ROADMAP', () => { + writeMinimalProjectMd(tmpDir); + // ROADMAP has no phases + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\nNo phases listed.\n' + ); + writeMinimalStateMd(tmpDir, '# Session State\n\nNo phase refs.\n'); + writeValidConfigJson(tmpDir); + // Orphan phase dir not in ROADMAP + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '99-orphan'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W007'), + `Expected W007 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + // ─── Overall status ──────────────────────────────────────────────────────── + + test("returns 'healthy' when all checks pass", () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir); + // Create valid phase dir matching ROADMAP + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-a'); + fs.mkdirSync(phaseDir, { recursive: true }); + // Add PLAN+SUMMARY so no I001 + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.status, 'healthy', `Expected healthy, got ${output.status}. Errors: ${JSON.stringify(output.errors)}, Warnings: ${JSON.stringify(output.warnings)}`); + assert.deepStrictEqual(output.errors, [], 'should have no errors'); + assert.deepStrictEqual(output.warnings, [], 'should have no warnings'); + }); + + test("returns 'degraded' when only warnings exist", () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir); + // No config.json → W003 (warning, not error) + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.status, 'degraded', `Expected degraded, got ${output.status}`); + assert.strictEqual(output.errors.length, 0, 'should have no errors'); + assert.ok(output.warnings.length > 0, 'should have warnings'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// validate health --repair command +// ───────────────────────────────────────────────────────────────────────────── + +describe('validate health --repair command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Set up base project with ROADMAP and PROJECT.md so repairs are triggered + // (E001, E003 are not repairable so we always need .planning/ and ROADMAP.md) + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('creates config.json with defaults when missing', () => { + // STATE.md present so no STATE repair; no config.json + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + // Ensure no config.json + const configPath = path.join(tmpDir, '.planning', 'config.json'); + if (fs.existsSync(configPath)) fs.unlinkSync(configPath); + + const result = runGsdTools('validate health --repair', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + Array.isArray(output.repairs_performed), + `Expected repairs_performed array: ${JSON.stringify(output)}` + ); + const createAction = output.repairs_performed.find(r => r.action === 'createConfig'); + assert.ok(createAction, `Expected createConfig action: ${JSON.stringify(output.repairs_performed)}`); + assert.strictEqual(createAction.success, true, 'createConfig should succeed'); + + // Verify config.json now exists on disk with valid JSON and balanced profile + assert.ok(fs.existsSync(configPath), 'config.json should now exist on disk'); + const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.strictEqual(diskConfig.model_profile, 'balanced', 'default model_profile should be balanced'); + }); + + test('resets config.json when JSON is invalid', () => { + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, '{broken json'); + + const result = runGsdTools('validate health --repair', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + Array.isArray(output.repairs_performed), + `Expected repairs_performed: ${JSON.stringify(output)}` + ); + const resetAction = output.repairs_performed.find(r => r.action === 'resetConfig'); + assert.ok(resetAction, `Expected resetConfig action: ${JSON.stringify(output.repairs_performed)}`); + + // Verify config.json is now valid JSON + const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.ok(typeof diskConfig === 'object', 'config.json should be valid JSON after repair'); + }); + + test('regenerates STATE.md when missing', () => { + writeValidConfigJson(tmpDir); + // No STATE.md + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + if (fs.existsSync(statePath)) fs.unlinkSync(statePath); + + const result = runGsdTools('validate health --repair', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + Array.isArray(output.repairs_performed), + `Expected repairs_performed: ${JSON.stringify(output)}` + ); + const regenerateAction = output.repairs_performed.find(r => r.action === 'regenerateState'); + assert.ok(regenerateAction, `Expected regenerateState action: ${JSON.stringify(output.repairs_performed)}`); + assert.strictEqual(regenerateAction.success, true, 'regenerateState should succeed'); + + // Verify STATE.md now exists and contains "# Session State" + assert.ok(fs.existsSync(statePath), 'STATE.md should now exist on disk'); + const stateContent = fs.readFileSync(statePath, 'utf-8'); + assert.ok(stateContent.includes('# Session State'), 'regenerated STATE.md should contain "# Session State"'); + }); + + test('backs up existing STATE.md before regenerating', () => { + writeValidConfigJson(tmpDir); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const originalContent = '# Session State\n\nOriginal content here.\n'; + fs.writeFileSync(statePath, originalContent); + + // Make STATE.md reference a nonexistent phase so repair is triggered + fs.writeFileSync( + statePath, + '# Session State\n\nPhase 99 is current.\n' + ); + + const result = runGsdTools('validate health --repair', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + Array.isArray(output.repairs_performed), + `Expected repairs_performed: ${JSON.stringify(output)}` + ); + + // Verify a .bak- file exists alongside STATE.md + const planningDir = path.join(tmpDir, '.planning'); + const planningFiles = fs.readdirSync(planningDir); + const backupFile = planningFiles.find(f => f.startsWith('STATE.md.bak-')); + assert.ok(backupFile, `Expected a STATE.md.bak- file. Found files: ${planningFiles.join(', ')}`); + + // Verify backup contains the original content + const backupContent = fs.readFileSync(path.join(planningDir, backupFile), 'utf-8'); + assert.ok(backupContent.includes('Phase 99'), 'backup should contain the original STATE.md content'); + }); + + test('reports repairable_count correctly', () => { + // No config.json (W003, repairable=true) and no STATE.md (E004, repairable=true) + const configPath = path.join(tmpDir, '.planning', 'config.json'); + if (fs.existsSync(configPath)) fs.unlinkSync(configPath); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + if (fs.existsSync(statePath)) fs.unlinkSync(statePath); + + // Run WITHOUT --repair to just check repairable_count + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.repairable_count >= 2, + `Expected repairable_count >= 2, got ${output.repairable_count}. Full output: ${JSON.stringify(output)}` + ); + }); +});