// scripts/library-decisions/schema.mjs // Zod-validated schema for library decision trace files. // Shared by: evaluate-library skill, pre-commit check, sandcastle reviewer. import { z } from "zod"; import fs from "node:fs"; // ---- Zod schema ---- const filterResultsSchema = z .object({ license: z.string().min(1), types: z.string().min(1), maintenance: z.enum(["active", "dormant", "abandoned"]), "boundary-fit": z.enum(["pass", "fail"]), "shadow-check": z.string().min(1), "eu-residency": z.enum(["ok", "n/a", "self-hostable", "fail"]), "cve-scan": z.string().min(1), "named-consumer": z.enum(["pass", "fail"]), socketRisk: z.union([z.literal("clean"), z.literal("flagged"), z.string()]), }) .strict(); export const traceSchema = z .object({ package: z.string().min(1), version: z.string().min(1), tier: z.enum(["app", "feature", "core"]), decision: z.enum(["approved", "rejected"]), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), deciders: z.array(z.string()), adr: z.string().nullable(), lastRevalidated: z.string().nullable(), "filter-results": filterResultsSchema, "verification-commands": z.array(z.string()), "accepted-cves": z.array(z.string()).optional(), }) .strict(); // ---- Helpers ---- /** Strip surrounding single- or double-quotes from a YAML scalar. */ function unquote(s) { if ( (s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")) ) { return s.slice(1, -1); } return s; } /** Parse a YAML inline flow array: `[a, "b", c]` → `["a", "b", "c"]`. */ function parseInlineArray(rawValue) { const inner = rawValue.slice(1, -1).trim(); return inner === "" ? [] : inner.split(",").map((s) => unquote(s.trim())); } /** * Consume indented children (nested object or block-sequence array) starting * at `startIdx` in `lines`. Returns the parsed value and the index of the * first non-indented line (i.e. where the parent should resume scanning). */ function parseNestedBlock(lines, startIdx) { const nested = {}; const arr = []; let i = startIdx; while (i < lines.length && /^ {2}/.test(lines[i])) { const child = lines[i]; const itemMatch = child.match(/^ {2}- (.+)$/); if (itemMatch) { arr.push(unquote(itemMatch[1].trim())); } else { const nestedMatch = child.match(/^ {2}([\w-]+):\s*(.*)$/); if (nestedMatch) { nested[nestedMatch[1]] = unquote(nestedMatch[2].trim()); } } i++; } return { value: arr.length > 0 ? arr : nested, nextIdx: i }; } /** * Parse the YAML frontmatter of a markdown file into a plain JS object. * Handles the trace format: scalar values, inline flow arrays, one-level * nested objects, and block-sequence arrays. */ export function parseFrontmatter(text) { const match = text.match(/^---\n([\s\S]+?)\n---/); if (!match) throw new Error("No YAML frontmatter found"); const lines = match[1].split("\n"); const result = {}; let i = 0; while (i < lines.length) { const line = lines[i]; const topMatch = line.match(/^([\w-]+):\s*(.*)$/); if (!topMatch) { i++; continue; } const key = topMatch[1]; const rawValue = topMatch[2].trim(); if (rawValue === "") { const { value, nextIdx } = parseNestedBlock(lines, i + 1); result[key] = value; i = nextIdx; } else if (rawValue.startsWith("[") && rawValue.endsWith("]")) { result[key] = parseInlineArray(rawValue); i++; } else { const v = unquote(rawValue); result[key] = v === "null" ? null : v; i++; } } return result; } // ---- Public API ---- /** * Validate an already-parsed frontmatter object against the trace schema. * Throws ZodError on failure; returns the parsed (typed) value on success. */ export function validateTrace(raw) { return traceSchema.parse(raw); } /** * Read a trace `.md` file, parse its frontmatter, and validate it. * Throws on missing frontmatter or schema violations. */ export function parseTrace(filePath) { const text = fs.readFileSync(filePath, "utf8"); const raw = parseFrontmatter(text); return validateTrace(raw); }