Add socketRisk (9th filter result) and lastRevalidated (nullable ISO date) to the library-decision trace schema. Downstream enforcement layers (evaluate-library skill, check.mjs major-bump mode, revalidate.mjs cron) all depend on these fields being validated at the schema layer first. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
146 lines
4.1 KiB
JavaScript
146 lines
4.1 KiB
JavaScript
// 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);
|
|
}
|