import { createHash } from "node:crypto"; import { parse as parseYaml } from "yaml"; import type { BacklogKind } from "./kinds"; import { isBacklogKind } from "./kinds"; import { epicFolderFromPath, planSlugFromPath } from "./paths"; const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; export type ParsedBacklogFile = { contentHash: string; frontmatter: Record; bodyMarkdown: string; kind: BacklogKind; slug: string; planSlug: string; epicSlug: string | null; title: string; status: string | null; priority: string | null; owner: string | null; }; function inferKindFromFilename(filename: string): BacklogKind | null { if (filename.startsWith("Plan-")) return "plan"; if (filename.startsWith("Epic-")) return "epic"; if (filename.startsWith("Task-")) return "task"; return null; } function readString(fm: Record, key: string): string | null { const v = fm[key]; return typeof v === "string" && v.trim() ? v.trim() : null; } function firstHeading(markdown: string): string | null { const m = markdown.match(/^\s*#\s+(.+)$/m); return m?.[1]?.trim() ?? null; } export function hashFileContents(raw: string): string { return createHash("sha256").update(raw, "utf8").digest("hex"); } /** * Parse a markdown document with optional YAML frontmatter. * `repoPathPosix` is used to infer plan/epic segments when frontmatter omits them. */ export function parseBacklogMarkdown( raw: string, repoPathPosix: string, ): ParsedBacklogFile { const contentHash = hashFileContents(raw); let frontmatter: Record = {}; let bodyMarkdown = raw; const fmMatch = raw.match(FRONTMATTER); if (fmMatch) { try { const parsed = parseYaml(fmMatch[1]); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { frontmatter = parsed as Record; } } catch { frontmatter = {}; } bodyMarkdown = raw.slice(fmMatch[0].length); } const filename = repoPathPosix.split("/").pop() ?? ""; const baseName = filename.replace(/\.md$/i, ""); const fmKind = readString(frontmatter, "kind"); const inferred = inferKindFromFilename(filename); const kind: BacklogKind = fmKind && isBacklogKind(fmKind) ? fmKind : inferred ?? "task"; const slug = readString(frontmatter, "slug") ?? (baseName.replace(/^(Plan|Epic|Task)-/, "") || baseName); const planSlug = readString(frontmatter, "plan_slug") ?? planSlugFromPath(repoPathPosix) ?? slug; const epicSlug = readString(frontmatter, "epic_slug") ?? (kind === "task" ? epicFolderFromPath(repoPathPosix) : null); const title = readString(frontmatter, "title") ?? firstHeading(bodyMarkdown) ?? baseName; return { contentHash, frontmatter, bodyMarkdown, kind, slug, planSlug, epicSlug, title, status: readString(frontmatter, "status"), priority: readString(frontmatter, "priority"), owner: readString(frontmatter, "owner"), }; }