Files
agentic-dev/scripts/library-decisions/schema.mjs
Danijel Martinek eccd8b0cc1 fix(library-decisions): traceSchema accepts the committed trace shape
The strict schema rejected 38 of 39 approved traces in
docs/library-decisions: lastRevalidated was required (most traces omit
it), and the compliance frontmatter fields the _template.md documents
(is-sub-processor, processes-pii, plus the sub-processor block:
data-sent, region, dpa-signed, sccs-required, contact) were
unrecognized keys. Make lastRevalidated optional, add the compliance
fields (booleanish coercion for YAML string scalars), and require
data-sent when is-sub-processor is true. New loop test asserts every
committed trace parses so the schema can never drift away from the
repo's own corpus again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:47:18 +02:00

174 lines
5.4 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 ----
/** YAML scalars arrive as strings — coerce "true"/"false" (or booleans). */
const booleanish = z.preprocess(
(v) => (v === "true" ? true : v === "false" ? false : v),
z.boolean(),
);
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(),
// Optional: most committed traces have never been revalidated and omit
// the key entirely (requiring it rejected 38 of 39 approved traces).
lastRevalidated: z.string().nullable().optional(),
"filter-results": filterResultsSchema,
"verification-commands": z.array(z.string()),
"accepted-cves": z.array(z.string()).optional(),
// ADR-022 amendment (compliance frontmatter, see _template.md): every
// newer trace declares whether the dependency ships data to a third
// party; sub-processor traces carry the extra contact/DPA fields.
"is-sub-processor": booleanish.optional(),
"processes-pii": booleanish.optional(),
"data-sent": z.string().nullable().optional(),
region: z.string().nullable().optional(),
"dpa-signed": z.union([booleanish, z.string()]).nullable().optional(),
"sccs-required": z.union([booleanish, z.string()]).nullable().optional(),
contact: z.string().nullable().optional(),
})
.strict()
.superRefine((trace, ctx) => {
if (trace["is-sub-processor"] === true && trace["data-sent"] == null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"sub-processor traces must declare data-sent (what leaves the machine)",
path: ["data-sent"],
});
}
});
// ---- 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);
}