/** * Minimal frontmatter parser/serialiser for SDD spec files (DRAFT-001 v0.3.1). * * Targets the exact shape produced by `templates/spec.md`: a `---` fenced * block at the top of the file with line-oriented `key: value` entries. * No quoting, no anchors, no nested objects. We don't need a real YAML * parser; we need to read and round-trip a handful of known keys without * losing whatever else is in the block. * * Anything that doesn't match the expected shape is treated as * unparseable and the cockpit refuses to write to it (Q4 default). */ import * as fs from "node:fs"; export type SpecStatus = "draft" | "accepted" | "shipped" | "superseded"; export interface ParsedFrontmatter { /** The literal text of the frontmatter block, including the fence lines. */ raw: string; /** Lower-cased + trimmed key → trimmed value (or empty string). */ fields: Map; /** Original line order of keys (case-preserved), used by the serialiser. */ keyOrder: string[]; /** Per-key original line text, so unknown keys round-trip verbatim. */ rawLines: Map; } const FENCE = "---"; /** * Parse the frontmatter block of a markdown file. Returns null if the file * has no frontmatter at all. Returns an object even if the block exists but * is malformed in minor ways (so the caller can still read `status` if it * happened to land cleanly), but `writeFrontmatter` will refuse files whose * shape doesn't round-trip safely. */ export function parseFrontmatter(content: string): ParsedFrontmatter | null { const lines = content.split("\n"); if (lines.length === 0 || lines[0]!.trimEnd() !== FENCE) return null; let endIdx = -1; for (let i = 1; i < lines.length; i++) { if (lines[i]!.trimEnd() === FENCE) { endIdx = i; break; } } if (endIdx === -1) return null; const fields = new Map(); const keyOrder: string[] = []; const rawLines = new Map(); let prevKey: string | null = null; for (let i = 1; i < endIdx; i++) { const line = lines[i]!; if (line.trim() === "") { prevKey = null; continue; } // A continuation line (list items, indented multi-line values) attaches // to the previous key's raw text so we don't lose it on round-trip. if (/^\s+/.test(line) && prevKey !== null) { const merged = (rawLines.get(prevKey) ?? "") + "\n" + line; rawLines.set(prevKey, merged); continue; } const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line); if (!match) { prevKey = null; continue; } const key = match[1]!; const value = match[2]!.trim(); fields.set(key.toLowerCase(), value); keyOrder.push(key); rawLines.set(key, line); prevKey = key; } const raw = lines.slice(0, endIdx + 1).join("\n"); return { raw, fields, keyOrder, rawLines }; } /** Strict-parse a status value into the typed enum, or undefined if unknown. */ export function asStatus(v: string | undefined): SpecStatus | undefined { if (!v) return undefined; const s = v.toLowerCase(); if (s === "draft" || s === "accepted" || s === "shipped" || s === "superseded") return s; return undefined; } /** Next status in the lifecycle, or null if terminal (or superseded). */ export function nextStatus(s: SpecStatus): SpecStatus | null { if (s === "draft") return "accepted"; if (s === "accepted") return "shipped"; return null; } /** Read + parse, returning the typed status if present (Spec files only). */ export function readSpecStatus(absPath: string): { status: SpecStatus | undefined; fm: ParsedFrontmatter | null; } { let content = ""; try { content = fs.readFileSync(absPath, "utf8"); } catch { return { status: undefined, fm: null }; } const fm = parseFrontmatter(content); if (!fm) return { status: undefined, fm: null }; return { status: asStatus(fm.fields.get("status")), fm }; } /** * Apply a status flip to a spec file. Stamps `accepted:` or `shipped:` with * the supplied ISO date when transitioning *into* those statuses. Refuses * to write if the file's frontmatter doesn't round-trip cleanly (Q4). * * Returns the new file contents on success, or an error message on refusal. */ export function applyStatusFlip( absPath: string, next: SpecStatus, isoDate: string, ): { ok: true; contents: string } | { ok: false; reason: string } { let original: string; try { original = fs.readFileSync(absPath, "utf8"); } catch (e) { return { ok: false, reason: `cannot read file: ${(e as Error).message}` }; } const fm = parseFrontmatter(original); if (!fm) return { ok: false, reason: "no frontmatter block found" }; if (!fm.fields.has("status")) { return { ok: false, reason: "frontmatter has no `status:` field" }; } // Build a new frontmatter block by rewriting only the touched keys; everything // else round-trips verbatim. This preserves comments, ordering, and any // unknown fields a consumer project may have added. const lines = original.split("\n"); const fmEndIdx = findFrontmatterEnd(lines); if (fmEndIdx === -1) return { ok: false, reason: "malformed frontmatter (no closing fence)" }; const newFmLines: string[] = [lines[0]!]; // opening fence let stampedField: "accepted" | "shipped" | null = null; if (next === "accepted") stampedField = "accepted"; else if (next === "shipped") stampedField = "shipped"; const stampWritten = new Set(); for (let i = 1; i < fmEndIdx; i++) { const line = lines[i]!; const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line); if (!match) { newFmLines.push(line); continue; } const key = match[1]!; const lkey = key.toLowerCase(); if (lkey === "status") { newFmLines.push(`${key}: ${next}`); } else if (lkey === stampedField) { newFmLines.push(`${key}: ${isoDate}`); stampWritten.add(lkey); } else { newFmLines.push(line); } } // If the stamp field wasn't present in the frontmatter at all, append it // before the closing fence. if (stampedField && !stampWritten.has(stampedField)) { newFmLines.push(`${stampedField}: ${isoDate}`); } newFmLines.push(lines[fmEndIdx]!); // closing fence const after = lines.slice(fmEndIdx + 1).join("\n"); const newContents = newFmLines.join("\n") + (after.length > 0 ? "\n" + after : "\n"); return { ok: true, contents: newContents }; } function findFrontmatterEnd(lines: string[]): number { if (lines.length === 0 || lines[0]!.trimEnd() !== FENCE) return -1; for (let i = 1; i < lines.length; i++) { if (lines[i]!.trimEnd() === FENCE) return i; } return -1; } /** Format today's date as ISO yyyy-mm-dd in local time. */ export function todayIso(now: Date = new Date()): string { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; }