// b32 — Layer 1 pure helpers for goal files: // parseGoalFrontmatter(raw) → { meta, body } | null // findGoalByMergeSha(sha, all) → GoalEntry | null // Both functions are pure: string in, struct out, no I/O. The // handler (d21_handlers_goals) reads the file from disk and hands // the raw string here; the registry (a31_goals) is what's iterated // for the SHA lookup. Sibling test pins the parse + lookup // contracts per /sama/v2 §4.3. import type { GoalEntry, GoalStatus } from "./a31_goals.ts"; export interface GoalMeta { slug: string; title: string; date: string; branch: string; prNumber: number | null; mergeSha: string | null; status: GoalStatus; relatedPosts: string[]; } export interface ParsedGoal { meta: GoalMeta; body: string; } const STATUS_VALUES: ReadonlyArray = [ "pending", "shipped", "lossy", "lost", "abandoned", ]; const parseNullableInt = (v: string | undefined): number | null => { if (v === undefined || v === "null" || v === "") return null; const n = Number(v); return Number.isFinite(n) ? n : NaN; }; const parseNullableString = (v: string | undefined): string | null => { if (v === undefined || v === "null" || v === "") return null; return v; }; // Inline-array form only: `related_posts: [a, b, c]`. The block // list form (`- a\n- b\n`) is intentionally unsupported to keep // the parser small — every goal file should use the inline form. const parseInlineArray = (v: string | undefined): string[] | null => { if (v === undefined || v === "") return []; const m = v.match(/^\[(.*)\]$/); if (!m) return null; const inner = m[1]!.trim(); if (inner === "") return []; return inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0); }; export const parseGoalFrontmatter = (raw: string): ParsedGoal | null => { const lines = raw.split("\n"); if (lines[0]?.trim() !== "---") return null; let closeIdx = -1; for (let i = 1; i < lines.length; i++) { if (lines[i]?.trim() === "---") { closeIdx = i; break; } } if (closeIdx === -1) return null; const fields: Record = {}; for (let i = 1; i < closeIdx; i++) { const line = lines[i]!; const t = line.trim(); if (t === "" || t.startsWith("#")) continue; const colonIdx = line.indexOf(":"); if (colonIdx === -1) return null; const key = line.slice(0, colonIdx).trim(); const value = line.slice(colonIdx + 1).trim(); fields[key] = value; } for (const required of ["slug", "title", "date", "branch", "status"]) { if (fields[required] === undefined || fields[required] === "") return null; } const status = fields.status as GoalStatus; if (!STATUS_VALUES.includes(status)) return null; const prNumber = parseNullableInt(fields.pr_number); if (Number.isNaN(prNumber)) return null; const mergeSha = parseNullableString(fields.merge_sha); const relatedPosts = parseInlineArray(fields.related_posts); if (relatedPosts === null) return null; const body = lines.slice(closeIdx + 1).join("\n").replace(/^\n+/, ""); return { meta: { slug: fields.slug!, title: fields.title!, date: fields.date!, branch: fields.branch!, prNumber, mergeSha, status, relatedPosts, }, body, }; }; // SHA prefix matching in either direction so a 7-char URL SHA // resolves a 40-char stored SHA AND vice versa. export const findGoalByMergeSha = ( sha: string, all: ReadonlyArray, ): GoalEntry | null => { if (sha === "") return null; for (const goal of all) { if (goal.mergeSha === null) continue; if (goal.mergeSha.startsWith(sha) || sha.startsWith(goal.mergeSha)) { return goal; } } return null; };