feat(scripts): add library-decisions trace schema + template

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 05:10:06 +00:00
parent 16a03e026b
commit f8908e5e4b
5 changed files with 421 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
---
package: <name>
version: "<semver range>"
tier: app | feature | core
decision: approved | rejected
date: <YYYY-MM-DD>
deciders: [<author>, ...]
adr: adr-NNN | null
filter-results:
license: <SPDX id>
types: native | "@types/<x>" | none
maintenance: active | dormant | abandoned
boundary-fit: pass | fail
shadow-check: pass | fail | "shadows <x>"
eu-residency: ok | n/a | self-hostable | fail
cve-scan: clean | "<advisory-id>" | fail
named-consumer: pass | fail
verification-commands:
- <literal command that produced each filter result>
accepted-cves: []
---
## Filter: license
<!-- Result: <SPDX id> -->
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
<!-- Result: native | @types/<x> | none -->
Confirm TypeScript types are available (`native` = ships its own `.d.ts`;
`@types/<x>` = community types package exists; `none` = no types → auto-reject).
## Filter: maintenance
<!-- Result: active | dormant | abandoned -->
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
<!-- Result: pass | fail -->
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
<!-- Result: pass | fail | "shadows <x>" -->
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 <x>` and an
auto-reject; a replacement requires a dedicated ADR.
## Filter: eu-residency
<!-- Result: ok | n/a | self-hostable | fail -->
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
<!-- Result: clean | "<advisory-id>" | fail -->
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
<!-- Result: pass | fail -->
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
<!-- Required: answer in either direction with justification -->
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
<!-- Required: mechanical | hard | impossible + justification -->
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
<!-- Required: minimum two named alternatives, or "none with explanation" -->
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).

View File

@@ -28,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@ai-hero/sandcastle": "*", "@ai-hero/sandcastle": "*",
"zod": "^3.25.0",
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@stryker-mutator/core": "^8.7.0", "@stryker-mutator/core": "^8.7.0",
"@stryker-mutator/vitest-runner": "^8.7.0", "@stryker-mutator/vitest-runner": "^8.7.0",

3
pnpm-lock.yaml generated
View File

@@ -43,6 +43,9 @@ importers:
typescript: typescript:
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.9.3 version: 5.9.3
zod:
specifier: ^3.25.0
version: 3.25.76
apps/cms: apps/cms:
dependencies: dependencies:

View File

@@ -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);
}

View File

@@ -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);
});
});