From f8908e5e4bfeeb2b9daeb0e78816d48775f9fb42 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 14 May 2026 05:10:06 +0000 Subject: [PATCH] feat(scripts): add library-decisions trace schema + template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates the shared schema module for library evaluation traces (ADR-022 §4): Zod-validated frontmatter with all 8 filter fields and enum constraints, plus parseTrace/validateTrace exports and a custom YAML frontmatter parser for the nested trace format. Also adds docs/library-decisions/_template.md with all 11 required headings (8 Filter + 3 Prompt) in machine-checkable ADR-022 order. Adds zod as a root devDependency so the script is runnable directly from the workspace root without a package context. Co-Authored-By: Claude Sonnet 4.6 --- docs/library-decisions/_template.md | 111 +++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 + scripts/library-decisions/schema.mjs | 143 +++++++++++++++++++ scripts/library-decisions/schema.test.mjs | 163 ++++++++++++++++++++++ 5 files changed, 421 insertions(+) create mode 100644 docs/library-decisions/_template.md create mode 100644 scripts/library-decisions/schema.mjs create mode 100644 scripts/library-decisions/schema.test.mjs diff --git a/docs/library-decisions/_template.md b/docs/library-decisions/_template.md new file mode 100644 index 0000000..1f16bb0 --- /dev/null +++ b/docs/library-decisions/_template.md @@ -0,0 +1,111 @@ +--- +package: +version: "" +tier: app | feature | core +decision: approved | rejected +date: +deciders: [, ...] +adr: adr-NNN | null +filter-results: + license: + types: native | "@types/" | none + maintenance: active | dormant | abandoned + boundary-fit: pass | fail + shadow-check: pass | fail | "shadows " + eu-residency: ok | n/a | self-hostable | fail + cve-scan: clean | "" | fail + named-consumer: pass | fail +verification-commands: + - +accepted-cves: [] +--- + +## Filter: license + + + +Allowed licences: MIT, Apache-2.0, BSD-\*, ISC, MPL-2.0. Record the SPDX +identifier from `package.json` or `npx license-checker`. Anything outside the +allowlist is an automatic reject. + +## Filter: types + + + +Confirm TypeScript types are available (`native` = ships its own `.d.ts`; +`@types/` = community types package exists; `none` = no types → auto-reject). + +## Filter: maintenance + + + +Check last release date and recent PR/issue activity. `active` = last release +< 18 months AND activity < 12 months. `dormant` = stable but not actively +developed (acceptable for finished libraries). `abandoned` = auto-reject. + +## Filter: boundary-fit + + + +Confirm the dependency does not violate ESLint boundary-tag rules for the +target tier (ADR-006, ADR-010, ADR-017). E.g., a Sentry SDK added to a feature +package is an auto-reject because ADR-017 §4 reserves vendor SDKs for core. + +## Filter: shadow-check + + + +Check whether this library duplicates a must-have already locked in the +workspace (e.g., proposing `valibot` when `zod` is locked, `tsyringe` when +Inversify is locked by ADR-002). A parallel adoption is `shadows ` and an +auto-reject; a replacement requires a dedicated ADR. + +## Filter: eu-residency + + + +If the library transmits user data, telemetry, or business state to a +vendor-controlled endpoint by default, the vendor must offer an EU data region. +Self-hostable packages and build-time-only tools are `n/a`. + +## Filter: cve-scan + + + +Run `pnpm audit --audit-level=moderate`. `clean` = no advisories at adoption +time. Record any accepted advisory IDs in the `accepted-cves` frontmatter field +and explain the risk acceptance here. + +## Filter: named-consumer + + + +Answer: "Who calls this code path today, or who is blocked waiting for it?" +Hypothetical future callers are not consumers. This filter is the direct +response to the 2026-05-14 OpenAPI near-miss (ADR-022 §Context). + +## Prompt: replaces + + + +What existing library or approach does this replace? New-and-old running in +parallel is a smell — name the thing being retired and its retirement plan, or +explain why parallel adoption is intentional and time-bounded. + +## Prompt: migration-cost-out + + + +What does ripping this back out look like 18 months from now? Is the removal +mechanical (swap one package, update call sites), hard (scattered integration +points, data format dependencies), or impossible (vendor lock-in, protocol +coupling)? Higher migration cost raises the bar for adoption. + +## Prompt: alternatives-considered + + + +Name at least two alternatives evaluated before choosing this library. For +`core`-tier adoptions, this section is also duplicated into the companion ADR. +If no alternatives exist, explain why (e.g., the library is the de-facto +standard with no viable substitutes). diff --git a/package.json b/package.json index 0aa02dd..61c9d87 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@ai-hero/sandcastle": "*", + "zod": "^3.25.0", "@playwright/test": "^1.49.0", "@stryker-mutator/core": "^8.7.0", "@stryker-mutator/vitest-runner": "^8.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66940fc..fdd5a9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: typescript: specifier: ^5.8.0 version: 5.9.3 + zod: + specifier: ^3.25.0 + version: 3.25.76 apps/cms: dependencies: diff --git a/scripts/library-decisions/schema.mjs b/scripts/library-decisions/schema.mjs new file mode 100644 index 0000000..5b7f5a6 --- /dev/null +++ b/scripts/library-decisions/schema.mjs @@ -0,0 +1,143 @@ +// 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"]), + }) + .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(), + "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); +} diff --git a/scripts/library-decisions/schema.test.mjs b/scripts/library-decisions/schema.test.mjs new file mode 100644 index 0000000..8dc05e8 --- /dev/null +++ b/scripts/library-decisions/schema.test.mjs @@ -0,0 +1,163 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { validateTrace, parseTrace, parseFrontmatter } from "./schema.mjs"; + +function validRaw(overrides = {}) { + return { + package: "example-lib", + version: "^1.0.0", + tier: "feature", + decision: "approved", + date: "2026-05-14", + deciders: ["alice"], + adr: null, + "filter-results": { + license: "MIT", + types: "native", + maintenance: "active", + "boundary-fit": "pass", + "shadow-check": "pass", + "eu-residency": "ok", + "cve-scan": "clean", + "named-consumer": "pass", + }, + "verification-commands": ["pnpm audit --audit-level=moderate"], + ...overrides, + }; +} + +function writeTempTrace(frontmatter, body = "") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "trace-")); + const file = path.join(dir, "trace.md"); + fs.writeFileSync(file, `---\n${frontmatter}\n---\n\n${body}`); + return file; +} + +const VALID_FM = `package: example-lib +version: "^1.0.0" +tier: feature +decision: approved +date: 2026-05-14 +deciders: [alice, bob] +adr: null +filter-results: + license: MIT + types: native + maintenance: active + boundary-fit: pass + shadow-check: pass + eu-residency: ok + cve-scan: clean + named-consumer: pass +verification-commands: + - pnpm audit --audit-level=moderate`; + +describe("validateTrace > valid cases", () => { + test("valid trace round-trips without error", () => { + const result = validateTrace(validRaw()); + assert.equal(result.package, "example-lib"); + assert.equal(result["filter-results"].license, "MIT"); + assert.deepEqual(result["verification-commands"], [ + "pnpm audit --audit-level=moderate", + ]); + }); + + test("accepted-cves absent is valid", () => { + const result = validateTrace(validRaw()); + assert.equal(result["accepted-cves"], undefined); + }); + + test("accepted-cves present is valid", () => { + const result = validateTrace( + validRaw({ "accepted-cves": ["CVE-2024-0001"] }), + ); + assert.deepEqual(result["accepted-cves"], ["CVE-2024-0001"]); + }); + + test("null adr is valid", () => { + assert.equal(validateTrace(validRaw({ adr: null })).adr, null); + }); + + test("string adr is valid", () => { + assert.equal(validateTrace(validRaw({ adr: "adr-022" })).adr, "adr-022"); + }); +}); + +describe("validateTrace > rejection cases", () => { + test("missing required field throws", () => { + const raw = validRaw(); + delete raw.package; + assert.throws(() => validateTrace(raw), /invalid_type|Required/i); + }); + + test("invalid tier enum throws", () => { + assert.throws( + () => validateTrace(validRaw({ tier: "invalid" })), + /invalid_enum_value|Invalid enum value/i, + ); + }); + + test("invalid maintenance enum throws", () => { + const raw = validRaw({ + "filter-results": { + ...validRaw()["filter-results"], + maintenance: "stale", + }, + }); + assert.throws( + () => validateTrace(raw), + /invalid_enum_value|Invalid enum value/i, + ); + }); + + test("unknown key in filter-results rejected by strict schema", () => { + const raw = validRaw({ + "filter-results": { + ...validRaw()["filter-results"], + "unknown-filter": "x", + }, + }); + assert.throws( + () => validateTrace(raw), + /unrecognized_keys|Unrecognized key/i, + ); + }); +}); + +describe("parseFrontmatter", () => { + test("parses scalar, inline array, nested object, block array", () => { + const text = `---\n${VALID_FM}\n---\n\n## Filter: license`; + const raw = parseFrontmatter(text); + assert.equal(raw.package, "example-lib"); + assert.equal(raw.version, "^1.0.0"); + assert.deepEqual(raw.deciders, ["alice", "bob"]); + assert.equal(raw.adr, null); + assert.equal(raw["filter-results"].license, "MIT"); + assert.deepEqual(raw["verification-commands"], [ + "pnpm audit --audit-level=moderate", + ]); + }); + + test("throws when no frontmatter delimiters found", () => { + assert.throws( + () => parseFrontmatter("# No frontmatter"), + /No YAML frontmatter/, + ); + }); +}); + +describe("parseTrace", () => { + test("reads and validates a valid trace file", () => { + const file = writeTempTrace(VALID_FM, "## Filter: license\n\nok"); + assert.equal(parseTrace(file).package, "example-lib"); + }); + + test("throws on missing required field in file", () => { + const fm = VALID_FM.replace(/^package: example-lib\n/m, ""); + const file = writeTempTrace(fm, ""); + assert.throws(() => parseTrace(file), /invalid_type|Required/i); + }); +});