syntaxai/tdd.md · main · src / b32_goals_meta.ts

b32_goals_meta.ts 131 lines · 3752 bytes raw
// 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;
};