/** * Parse and validate features.md — the single source of truth for Hive. */ export interface Feature { number: number; title: string; description: string; dependencies: string; status: "pending" | "in_progress" | "done"; } export interface FeatureStats { done: Feature[]; inProgress: Feature[]; pending: Feature[]; // ready: deps satisfied blocked: Feature[]; // pending but deps not satisfied total: number; } export function parseFeaturesMd(content: string): Feature[] { const features: Feature[] = []; const blocks = content.split(/^## Feature /gm).slice(1); for (const block of blocks) { const headerMatch = block.match(/^(\d+):\s*(.+)/); if (!headerMatch) continue; const number = parseInt(headerMatch[1], 10); const title = headerMatch[2].trim(); const descMatch = block.match(/^- Description:\s*(.+)/m); const depMatch = block.match(/^- Dependencies:\s*(.+)/m); const statusMatch = block.match(/^- Status:\s*(.+)/m); features.push({ number, title, description: descMatch?.[1]?.trim() ?? "", dependencies: depMatch?.[1]?.trim() ?? "none", status: (statusMatch?.[1]?.trim() as Feature["status"]) ?? "pending", }); } return features; } export function getStats(features: Feature[]): FeatureStats { const done = features.filter((f) => f.status === "done"); const inProgress = features.filter((f) => f.status === "in_progress"); const doneNumbers = new Set(done.map((f) => f.number)); const pending = features.filter((f) => { if (f.status !== "pending") return false; if (f.dependencies === "none") return true; const deps = f.dependencies.match(/Feature (\d+)/g) ?? []; return deps.every((d) => doneNumbers.has(parseInt(d.replace("Feature ", ""), 10))); }); const blocked = features.filter((f) => { if (f.status !== "pending") return false; if (f.dependencies === "none") return false; const deps = f.dependencies.match(/Feature (\d+)/g) ?? []; return !deps.every((d) => doneNumbers.has(parseInt(d.replace("Feature ", ""), 10))); }); return { done, inProgress, pending, blocked, total: features.length }; } export function progressBar(done: number, total: number, width = 20): string { if (total === 0) return `[${"─".repeat(width)}] 0/0 (0%)`; const filled = Math.round((done / total) * width); const empty = width - filled; return `[${"#".repeat(filled)}${"-".repeat(empty)}] ${done}/${total} (${((done / total) * 100).toFixed(0)}%)`; } export function validateFeaturesMd(content: string): { valid: boolean; issues: string[] } { const issues: string[] = []; if (!content.startsWith("# Features")) { issues.push("File must start with '# Features'"); } const features = parseFeaturesMd(content); if (features.length === 0) { issues.push("No features found"); return { valid: false, issues }; } const validStatuses = new Set(["pending", "in_progress", "done"]); const featureNumbers = new Set(features.map((f) => f.number)); for (let i = 0; i < features.length; i++) { const f = features[i]; const expected = i + 1; if (f.number !== expected) { issues.push(`Feature ${f.number}: expected sequential number ${expected}`); } if (!f.description) { issues.push(`Feature ${f.number}: missing Description`); } if (!validStatuses.has(f.status)) { issues.push(`Feature ${f.number}: invalid status '${f.status}'`); } if (f.dependencies !== "none") { const deps = f.dependencies.match(/Feature (\d+)/g) ?? []; for (const dep of deps) { const depNum = parseInt(dep.replace("Feature ", ""), 10); if (!featureNumbers.has(depNum)) { issues.push(`Feature ${f.number}: references non-existent ${dep}`); } } } } return { valid: issues.length === 0, issues }; }