/** * Feature Extractor — parses plan.md, spec.md, tasks.md from feature directories * into structured ExtractedFeature records. * * These are already structured markdown generated by maina, so parsing * is deterministic (not LLM-dependent). */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import type { Result } from "../../db/index"; import type { ExtractedFeature, TaskItem } from "../types"; // ─── Parsing Helpers ───────────────────────────────────────────────────── function readOptionalFile(path: string): string | null { if (!existsSync(path)) return null; try { return readFileSync(path, "utf-8"); } catch { return null; } } /** * Extract title from plan.md or spec.md heading. * Handles: * "# Implementation Plan: Token Refresh" * "# Feature: Token Refresh" * "# Token Refresh" */ function extractTitle(content: string): string { const firstLine = content.split("\n")[0] ?? ""; const heading = firstLine.replace(/^#+\s*/, ""); // Strip common prefixes const prefixes = ["Implementation Plan:", "Feature:", "Plan:"]; for (const prefix of prefixes) { if (heading.startsWith(prefix)) { return heading.slice(prefix.length).trim(); } } return heading.trim(); } /** * Extract tasks from markdown checklist format. * Supports: "- [ ] T001: description" and "- [x] T002: description" */ function extractTasks(content: string): TaskItem[] { const tasks: TaskItem[] = []; const lines = content.split("\n"); for (const line of lines) { const match = line.match(/^-\s+\[([ xX])\]\s+(T\d+):\s+(.+)/); if (match?.[2]) { tasks.push({ id: match[2], description: (match[3] ?? "").trim(), completed: match[1] !== " ", }); } } return tasks; } /** * Extract acceptance criteria / assertions from spec.md. * Looks for checklist items under Acceptance Criteria section. */ function extractSpecAssertions(specContent: string): string[] { const assertions: string[] = []; const lines = specContent.split("\n"); let inSection = false; for (const line of lines) { // Detect start of acceptance criteria section if (/^##\s+Acceptance Criteria/i.test(line)) { inSection = true; continue; } // End of section at next heading if (inSection && /^##\s/.test(line)) { break; } if (inSection) { const match = line.match(/^-\s+\[[ xX]?\]\s+(.+)/); if (match?.[1]) { assertions.push(match[1].trim()); } } } return assertions; } /** * Extract scope from spec.md. Looks for text under ## Scope section. */ function extractScope(specContent: string): string { const lines = specContent.split("\n"); let inSection = false; const scopeLines: string[] = []; for (const line of lines) { if (/^##\s+Scope/i.test(line)) { inSection = true; continue; } if (inSection && /^##\s/.test(line)) { break; } if (inSection && line.trim()) { scopeLines.push(line.trim()); } } return scopeLines.join(" "); } // ─── Public API ────────────────────────────────────────────────────────── export function extractSingleFeature( featureDir: string, ): Result { if (!existsSync(featureDir)) { return { ok: false, error: `Feature directory does not exist: ${featureDir}`, }; } const id = basename(featureDir); const planContent = readOptionalFile(join(featureDir, "plan.md")); const specContent = readOptionalFile(join(featureDir, "spec.md")); const tasksContent = readOptionalFile(join(featureDir, "tasks.md")); // Extract title from plan or spec let title = ""; if (planContent) { title = extractTitle(planContent); } else if (specContent) { title = extractTitle(specContent); } // Extract tasks from tasks.md or plan.md let tasks: TaskItem[] = []; if (tasksContent) { tasks = extractTasks(tasksContent); } else if (planContent) { tasks = extractTasks(planContent); } // Extract spec assertions and scope const specAssertions = specContent ? extractSpecAssertions(specContent) : []; const scope = specContent ? extractScope(specContent) : ""; return { ok: true, value: { id, title, scope, specQualityScore: 0, specAssertions, tasks, entitiesModified: [], decisionsCreated: [], branch: "", prNumber: null, merged: false, }, }; } export function extractFeatures( featuresDir: string, ): Result { if (!existsSync(featuresDir)) { return { ok: false, error: `Features directory does not exist: ${featuresDir}`, }; } const entries = readdirSync(featuresDir); const features: ExtractedFeature[] = []; for (const entry of entries) { const entryPath = join(featuresDir, entry); try { const stat = statSync(entryPath); if (!stat.isDirectory()) continue; } catch { continue; } const result = extractSingleFeature(entryPath); if (result.ok) { features.push(result.value); } } return { ok: true, value: features }; }