syntaxai/tdd.md · main · src / b32_goals_meta.ts
// 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<GoalStatus> = [
"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<string, string> = {};
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>,
): 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;
};