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:
111
docs/library-decisions/_template.md
Normal file
111
docs/library-decisions/_template.md
Normal 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).
|
||||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
143
scripts/library-decisions/schema.mjs
Normal file
143
scripts/library-decisions/schema.mjs
Normal 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);
|
||||||
|
}
|
||||||
163
scripts/library-decisions/schema.test.mjs
Normal file
163
scripts/library-decisions/schema.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user