chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
452
scripts/library-decisions/check.mjs
Normal file
452
scripts/library-decisions/check.mjs
Normal file
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pre-commit guard: refuses the commit if a new runtime dependency in a
|
||||
* feature- or core-tier package lacks a staged approved library trace.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all staged deps have approved traces (or are app-tier / devDeps / peerDeps)
|
||||
* 1 — one or more feature/core deps are missing an approved trace
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseFrontmatter } from "./schema.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
/** Derive package tier from its repo-relative path. */
|
||||
function deriveTier(relPath) {
|
||||
if (relPath.startsWith("apps/")) return "app";
|
||||
if (relPath.startsWith("packages/core-")) return "core";
|
||||
if (relPath.startsWith("packages/")) return "feature";
|
||||
return "skip"; // root package.json or unknown path
|
||||
}
|
||||
|
||||
function stagedFilesList(repoRoot, baseRef) {
|
||||
const cmd = baseRef
|
||||
? `git diff ${baseRef}...HEAD --name-only`
|
||||
: "git diff --cached --name-only";
|
||||
return execSync(cmd, { cwd: repoRoot, encoding: "utf8" })
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the names of runtime deps that are new in the staged version of
|
||||
* relPath compared to HEAD. Returns [] when the file can't be read.
|
||||
*
|
||||
* When baseRef is set, compares HEAD against baseRef instead of the index.
|
||||
*/
|
||||
function getNewRuntimeDeps(relPath, repoRoot, baseRef) {
|
||||
const currentRef = baseRef ? `HEAD:${relPath}` : `:${relPath}`;
|
||||
const ancestorRef = baseRef ? `${baseRef}:${relPath}` : `HEAD:${relPath}`;
|
||||
|
||||
let staged;
|
||||
try {
|
||||
staged = JSON.parse(
|
||||
execSync(`git show "${currentRef}"`, { cwd: repoRoot, encoding: "utf8" }),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let base = {};
|
||||
try {
|
||||
base = JSON.parse(
|
||||
execSync(`git show "${ancestorRef}"`, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// New file or initial commit — treat all deps as new
|
||||
}
|
||||
const baseDeps = new Set(Object.keys(base.dependencies ?? {}));
|
||||
const stagedDeps = staged.dependencies ?? {};
|
||||
return Object.keys(stagedDeps).filter(
|
||||
(d) =>
|
||||
!baseDeps.has(d) &&
|
||||
// Workspace-protocol entries are internal monorepo packages — not
|
||||
// third-party libraries, so they don't require a library trace.
|
||||
!String(stagedDeps[d]).startsWith("workspace:"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the staged-files list for a trace file whose name ends with
|
||||
* `-<depName>.md` inside docs/library-decisions/.
|
||||
*/
|
||||
function findStagedTrace(depName, staged) {
|
||||
const safe = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^docs/library-decisions/[^/]+-${safe}\\.md$`);
|
||||
return staged.find((f) => re.test(f)) ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renovate-PR mode helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCurrentBranch(repoRoot) {
|
||||
try {
|
||||
return execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLockfileDiff(repoRoot) {
|
||||
try {
|
||||
return execSync("git diff origin/main -- pnpm-lock.yaml", {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a pnpm lockfile diff line for a package version entry.
|
||||
* Handles both pnpm v6 format (`/pkg@ver:`) and v9 format (` pkg@ver: {}`),
|
||||
* and both scoped (`@scope/name`) and unscoped packages.
|
||||
*/
|
||||
const LOCKFILE_LINE_RE =
|
||||
/^([+-])\s*\/?(@[\w.-]+\/[\w.-]+|[\w.-]+)@(\d[-\w.+]*)(?=\s|:|$)/;
|
||||
|
||||
/** Parse a lockfile diff and return a Map of { depName → { from, to } }. */
|
||||
function parseLockfileDiff(diff) {
|
||||
const removed = new Map();
|
||||
const added = new Map();
|
||||
for (const line of diff.split("\n")) {
|
||||
const m = LOCKFILE_LINE_RE.exec(line);
|
||||
if (!m) continue;
|
||||
const [, sign, name, version] = m;
|
||||
if (sign === "-") removed.set(name, version);
|
||||
else added.set(name, version);
|
||||
}
|
||||
const bumped = new Map();
|
||||
for (const [name, to] of added) {
|
||||
const from = removed.get(name);
|
||||
if (from && from !== to) bumped.set(name, { from, to });
|
||||
}
|
||||
return bumped;
|
||||
}
|
||||
|
||||
/** Classify a version bump as "major", "minor", "patch", or "unknown". */
|
||||
function classifyBump(from, to) {
|
||||
const m1 = from.match(/^(\d+)\.(\d+)/);
|
||||
const m2 = to.match(/^(\d+)\.(\d+)/);
|
||||
if (!m1 || !m2) return "unknown";
|
||||
const fromMaj = +m1[1],
|
||||
toMaj = +m2[1];
|
||||
const fromMin = +m1[2],
|
||||
toMin = +m2[2];
|
||||
if (toMaj > fromMaj) return "major";
|
||||
if (toMaj === fromMaj && toMin > fromMin) return "minor";
|
||||
return "patch";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the set of direct runtime dependencies declared by all feature- and
|
||||
* core-tier packages (packages/* on disk). App-tier deps are excluded.
|
||||
*/
|
||||
function getFeatureCoreDeps(repoRoot) {
|
||||
const deps = new Set();
|
||||
const packagesDir = path.join(repoRoot, "packages");
|
||||
if (!fs.existsSync(packagesDir)) return deps;
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(packagesDir);
|
||||
} catch {
|
||||
return deps;
|
||||
}
|
||||
for (const pkg of entries) {
|
||||
const pkgJsonPath = path.join(packagesDir, pkg, "package.json");
|
||||
if (!fs.existsSync(pkgJsonPath)) continue;
|
||||
try {
|
||||
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
|
||||
for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
|
||||
deps.add(dep);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed package.json
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an existing (committed) trace file for depName under
|
||||
* docs/library-decisions/ inside repoRoot. Returns the absolute path or null.
|
||||
*/
|
||||
function findExistingTrace(depName, repoRoot) {
|
||||
const traceDir = path.join(repoRoot, "docs", "library-decisions");
|
||||
if (!fs.existsSync(traceDir)) return null;
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(traceDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (depName.startsWith("@")) {
|
||||
// Scoped package: @scope/name → look for a dir *-@scope, then name.md inside
|
||||
const slashIdx = depName.indexOf("/");
|
||||
const scope = depName.slice(0, slashIdx); // e.g. "@sentry"
|
||||
const name = depName.slice(slashIdx + 1); // e.g. "node"
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(`-${scope}`)) continue;
|
||||
const entryPath = path.join(traceDir, entry);
|
||||
try {
|
||||
if (!fs.statSync(entryPath).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const tracePath = path.join(entryPath, `${name}.md`);
|
||||
if (fs.existsSync(tracePath)) return tracePath;
|
||||
}
|
||||
} else {
|
||||
// Unscoped package: look for *-<name>.md
|
||||
const safe = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^[^/]+-${safe}\\.md$`);
|
||||
for (const entry of entries) {
|
||||
if (!re.test(entry)) continue;
|
||||
const entryPath = path.join(traceDir, entry);
|
||||
try {
|
||||
if (!fs.statSync(entryPath).isFile()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
return entryPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renovate-PR mode: for every feature/core-tier dep that receives a major
|
||||
* version bump, the corresponding trace must have lastRevalidated === today.
|
||||
*
|
||||
* Returns an array of error objects; empty array means clean.
|
||||
* { dep, from, to, reason: "stale" | "no-trace" | "parse-error", ... }
|
||||
*/
|
||||
export function checkRenovatePr(
|
||||
repoRoot = DEFAULT_REPO_ROOT,
|
||||
{ branch, diff, today } = {},
|
||||
) {
|
||||
const effectiveBranch =
|
||||
branch ?? process.env.GITHUB_HEAD_REF ?? getCurrentBranch(repoRoot);
|
||||
|
||||
if (!effectiveBranch?.startsWith("renovate/")) return [];
|
||||
|
||||
const lockfileDiff = diff ?? getLockfileDiff(repoRoot);
|
||||
const todayStr = today ?? new Date().toISOString().slice(0, 10);
|
||||
const bumped = parseLockfileDiff(lockfileDiff);
|
||||
const featureCoreDeps = getFeatureCoreDeps(repoRoot);
|
||||
const errors = [];
|
||||
|
||||
for (const [depName, { from, to }] of bumped) {
|
||||
if (!featureCoreDeps.has(depName)) continue;
|
||||
if (classifyBump(from, to) !== "major") continue;
|
||||
|
||||
const traceFile = findExistingTrace(depName, repoRoot);
|
||||
if (!traceFile) {
|
||||
errors.push({ dep: depName, from, to, reason: "no-trace" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(traceFile, "utf8");
|
||||
const fm = parseFrontmatter(content);
|
||||
if (fm.lastRevalidated !== todayStr) {
|
||||
errors.push({
|
||||
dep: depName,
|
||||
from,
|
||||
to,
|
||||
reason: "stale",
|
||||
lastRevalidated: fm.lastRevalidated ?? null,
|
||||
tracePath: path.relative(repoRoot, traceFile),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
dep: depName,
|
||||
from,
|
||||
to,
|
||||
reason: "parse-error",
|
||||
detail: String(e.message),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the library-decisions check against repoRoot.
|
||||
*
|
||||
* Returns an array of error objects, each with:
|
||||
* { pkgJson, dep, reason: "no-trace" | "not-approved" | "parse-error", ... }
|
||||
*
|
||||
* An empty array means the commit is clean.
|
||||
*/
|
||||
export function checkLibraryDecisions(
|
||||
repoRoot = DEFAULT_REPO_ROOT,
|
||||
{ stagedAgainst } = {},
|
||||
) {
|
||||
const staged = stagedFilesList(repoRoot, stagedAgainst);
|
||||
const pkgJsons = staged.filter(
|
||||
(f) => f === "package.json" || f.endsWith("/package.json"),
|
||||
);
|
||||
const errors = [];
|
||||
|
||||
for (const relPath of pkgJsons) {
|
||||
const tier = deriveTier(relPath);
|
||||
if (tier === "app" || tier === "skip") continue;
|
||||
|
||||
for (const dep of getNewRuntimeDeps(relPath, repoRoot, stagedAgainst)) {
|
||||
const stagedTrace = findStagedTrace(dep, staged);
|
||||
if (!stagedTrace) {
|
||||
// Fall back to an already-committed trace — if one exists and is
|
||||
// approved, the dep was previously evaluated and doesn't need
|
||||
// re-staging just because a new package adopts it.
|
||||
const committedTrace = findExistingTrace(dep, repoRoot);
|
||||
if (committedTrace) {
|
||||
try {
|
||||
const content = fs.readFileSync(committedTrace, "utf8");
|
||||
const fm = parseFrontmatter(content);
|
||||
if (fm.decision !== "approved") {
|
||||
errors.push({
|
||||
pkgJson: relPath,
|
||||
dep,
|
||||
reason: "not-approved",
|
||||
decision: fm.decision,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
pkgJson: relPath,
|
||||
dep,
|
||||
reason: "parse-error",
|
||||
detail: String(e.message),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
errors.push({ pkgJson: relPath, dep, reason: "no-trace" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const traceRef = stagedAgainst
|
||||
? `HEAD:${stagedTrace}`
|
||||
: `:${stagedTrace}`;
|
||||
const content = execSync(`git show "${traceRef}"`, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const fm = parseFrontmatter(content);
|
||||
if (fm.decision !== "approved") {
|
||||
errors.push({
|
||||
pkgJson: relPath,
|
||||
dep,
|
||||
reason: "not-approved",
|
||||
decision: fm.decision,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
pkgJson: relPath,
|
||||
dep,
|
||||
reason: "parse-error",
|
||||
detail: String(e.message),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// CLI entry point — only runs when executed directly, not when imported.
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes("--renovate-pr")) {
|
||||
let branch;
|
||||
const branchIdx = args.indexOf("--branch");
|
||||
if (branchIdx !== -1) {
|
||||
branch = args[branchIdx + 1];
|
||||
if (!branch || branch.startsWith("--")) {
|
||||
console.error("Error: --branch requires a branch name argument");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const renovateErrors = checkRenovatePr(DEFAULT_REPO_ROOT, { branch });
|
||||
if (!renovateErrors.length) process.exit(0);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
console.error(
|
||||
"✗ library-decisions/check: major version bump requires re-evaluation.\n",
|
||||
);
|
||||
for (const e of renovateErrors) {
|
||||
console.error(` ${e.dep} (${e.from} → ${e.to}):`);
|
||||
if (e.reason === "stale") {
|
||||
console.error(
|
||||
` ✗ trace lastRevalidated is "${e.lastRevalidated}" — must be today (${today})`,
|
||||
);
|
||||
console.error(` Trace: ${e.tracePath}`);
|
||||
} else if (e.reason === "no-trace") {
|
||||
console.error(` ✗ no trace found in docs/library-decisions/`);
|
||||
} else {
|
||||
console.error(` ✗ trace error — ${e.detail ?? ""}`);
|
||||
}
|
||||
console.error(
|
||||
` Run the evaluate-library skill: .claude/skills/evaluate-library/SKILL.md`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let stagedAgainst;
|
||||
const flagIdx = args.indexOf("--staged-against");
|
||||
if (flagIdx !== -1) {
|
||||
stagedAgainst = args[flagIdx + 1];
|
||||
if (!stagedAgainst || stagedAgainst.startsWith("--")) {
|
||||
console.error("Error: --staged-against requires a base ref argument");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const errors = checkLibraryDecisions(DEFAULT_REPO_ROOT, { stagedAgainst });
|
||||
if (!errors.length) process.exit(0);
|
||||
|
||||
console.error(
|
||||
"✗ library-decisions/check: new runtime deps require an approved trace.\n",
|
||||
);
|
||||
|
||||
const groups = {};
|
||||
for (const e of errors) {
|
||||
(groups[e.pkgJson] ??= []).push(e);
|
||||
}
|
||||
for (const [pkg, errs] of Object.entries(groups)) {
|
||||
console.error(` ${pkg}:`);
|
||||
for (const e of errs) {
|
||||
const msg =
|
||||
e.reason === "no-trace"
|
||||
? "no staged trace in docs/library-decisions/"
|
||||
: e.reason === "not-approved"
|
||||
? `trace decision is "${e.decision}" (expected "approved")`
|
||||
: `trace parse error — ${e.detail}`;
|
||||
console.error(` ✗ ${e.dep}: ${msg}`);
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
"\n Evaluate the library first: .claude/skills/evaluate-library/SKILL.md",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
383
scripts/library-decisions/check.test.mjs
Normal file
383
scripts/library-decisions/check.test.mjs
Normal file
@@ -0,0 +1,383 @@
|
||||
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 { execSync } from "node:child_process";
|
||||
import { checkLibraryDecisions, checkRenovatePr } from "./check.mjs";
|
||||
|
||||
/** Create a temp git repo with one initial commit so HEAD exists. */
|
||||
function makeRepo() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libcheck-"));
|
||||
const g = (cmd) => execSync(cmd, { cwd: dir, stdio: "pipe" });
|
||||
g("git init");
|
||||
g("git config user.email test@test.com");
|
||||
g("git config user.name Test");
|
||||
g("git config commit.gpgsign false");
|
||||
fs.writeFileSync(path.join(dir, ".gitkeep"), "");
|
||||
g("git add .gitkeep");
|
||||
g("git commit -m init");
|
||||
return { dir, g };
|
||||
}
|
||||
|
||||
/** Write a package.json under relDir and commit it as the baseline. */
|
||||
function commitPkg(dir, g, relDir, pkg) {
|
||||
const pkgDir = path.join(dir, relDir);
|
||||
fs.mkdirSync(pkgDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pkgDir, "package.json"),
|
||||
JSON.stringify(pkg, null, 2),
|
||||
);
|
||||
g(`git add ${relDir}/package.json`);
|
||||
g("git commit -m add-pkg");
|
||||
}
|
||||
|
||||
/** Overwrite a package.json and stage the result (no new commit). */
|
||||
function stagePkg(dir, g, relDir, pkg) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, relDir, "package.json"),
|
||||
JSON.stringify(pkg, null, 2),
|
||||
);
|
||||
g(`git add ${relDir}/package.json`);
|
||||
}
|
||||
|
||||
function traceFm(depName, decision) {
|
||||
return `package: ${depName}
|
||||
version: "^1.0.0"
|
||||
tier: feature
|
||||
decision: ${decision}
|
||||
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`;
|
||||
}
|
||||
|
||||
/** Write a trace file and stage it. */
|
||||
function stageTrace(dir, g, depName, decision = "approved") {
|
||||
const traceDir = path.join(dir, "docs", "library-decisions");
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
const file = `2026-05-14-${depName}.md`;
|
||||
fs.writeFileSync(
|
||||
path.join(traceDir, file),
|
||||
`---\n${traceFm(depName, decision)}\n---\n\n`,
|
||||
);
|
||||
g(`git add docs/library-decisions/${file}`);
|
||||
}
|
||||
|
||||
describe("checkLibraryDecisions", () => {
|
||||
test("new feature-tier dep without trace → exit 1", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "new-lib": "^1.0.0" },
|
||||
});
|
||||
|
||||
const errs = checkLibraryDecisions(dir);
|
||||
|
||||
assert.equal(errs.length, 1);
|
||||
assert.equal(errs[0].dep, "new-lib");
|
||||
assert.equal(errs[0].reason, "no-trace");
|
||||
});
|
||||
|
||||
test("new feature-tier dep with approved trace staged → exit 0", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "new-lib": "^1.0.0" },
|
||||
});
|
||||
stageTrace(dir, g, "new-lib", "approved");
|
||||
|
||||
assert.deepEqual(checkLibraryDecisions(dir), []);
|
||||
});
|
||||
|
||||
test("new feature-tier dep with rejected-decision trace staged → exit 1", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "new-lib": "^1.0.0" },
|
||||
});
|
||||
stageTrace(dir, g, "new-lib", "rejected");
|
||||
|
||||
const errs = checkLibraryDecisions(dir);
|
||||
|
||||
assert.equal(errs.length, 1);
|
||||
assert.equal(errs[0].dep, "new-lib");
|
||||
assert.equal(errs[0].reason, "not-approved");
|
||||
assert.equal(errs[0].decision, "rejected");
|
||||
});
|
||||
|
||||
test("new app-tier dep → exit 0", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "apps/web", { dependencies: {} });
|
||||
stagePkg(dir, g, "apps/web", { dependencies: { "new-lib": "^1.0.0" } });
|
||||
|
||||
assert.deepEqual(checkLibraryDecisions(dir), []);
|
||||
});
|
||||
|
||||
test("new devDependency → exit 0", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "packages/feat-a", {});
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
devDependencies: { "test-lib": "^1.0.0" },
|
||||
});
|
||||
|
||||
assert.deepEqual(checkLibraryDecisions(dir), []);
|
||||
});
|
||||
|
||||
test("multi-file diff with mixed pass/fail → exit 1 with per-package report", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
commitPkg(dir, g, "packages/feat-b", { dependencies: {} });
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "lib-a": "^1.0.0" },
|
||||
});
|
||||
stagePkg(dir, g, "packages/feat-b", {
|
||||
dependencies: { "lib-b": "^1.0.0" },
|
||||
});
|
||||
stageTrace(dir, g, "lib-a", "approved"); // feat-a passes; no trace for lib-b
|
||||
|
||||
const errs = checkLibraryDecisions(dir);
|
||||
|
||||
assert.equal(errs.length, 1);
|
||||
assert.equal(errs[0].pkgJson, "packages/feat-b/package.json");
|
||||
assert.equal(errs[0].dep, "lib-b");
|
||||
assert.equal(errs[0].reason, "no-trace");
|
||||
});
|
||||
|
||||
test("peerDependencies-only change → exit 0", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "packages/feat-a", {});
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
peerDependencies: { react: "^18.0.0" },
|
||||
});
|
||||
|
||||
assert.deepEqual(checkLibraryDecisions(dir), []);
|
||||
});
|
||||
|
||||
test("new workspace-protocol dep (internal monorepo package) → exit 0", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "@repo/core-shared": "workspace:*" },
|
||||
});
|
||||
|
||||
assert.deepEqual(checkLibraryDecisions(dir), []);
|
||||
});
|
||||
|
||||
test("new feature-tier dep with already-committed approved trace → exit 0", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
// Commit trace first (simulates an existing workspace-approved library)
|
||||
stageTrace(dir, g, "existing-lib", "approved");
|
||||
g("git commit -m add-trace");
|
||||
// Now add a new package that depends on the same library
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "existing-lib": "^1.0.0" },
|
||||
});
|
||||
|
||||
// No staged trace needed — the committed trace is the fallback
|
||||
assert.deepEqual(checkLibraryDecisions(dir), []);
|
||||
});
|
||||
|
||||
test("new feature-tier dep with already-committed rejected trace → exit 1", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
stageTrace(dir, g, "bad-lib", "rejected");
|
||||
g("git commit -m add-trace");
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
stagePkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "bad-lib": "^1.0.0" },
|
||||
});
|
||||
|
||||
const errs = checkLibraryDecisions(dir);
|
||||
assert.equal(errs.length, 1);
|
||||
assert.equal(errs[0].dep, "bad-lib");
|
||||
assert.equal(errs[0].reason, "not-approved");
|
||||
});
|
||||
|
||||
test("--staged-against mode: new feature-tier dep without trace → exit 1", () => {
|
||||
const { dir, g } = makeRepo();
|
||||
// Baseline commit: feature package with no deps
|
||||
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
|
||||
// Second commit: adds new-lib — no trace file committed alongside it
|
||||
commitPkg(dir, g, "packages/feat-a", {
|
||||
dependencies: { "new-lib": "^1.0.0" },
|
||||
});
|
||||
|
||||
// HEAD has new-lib; HEAD~1 doesn't — no trace in the diff → exit 1
|
||||
const errs = checkLibraryDecisions(dir, { stagedAgainst: "HEAD~1" });
|
||||
|
||||
assert.equal(errs.length, 1);
|
||||
assert.equal(errs[0].dep, "new-lib");
|
||||
assert.equal(errs[0].reason, "no-trace");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkRenovatePr — integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "renprc-"));
|
||||
}
|
||||
|
||||
function writeFixture(dir, relPath, content) {
|
||||
const full = path.join(dir, relPath);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function featurePkg(dir, name, deps) {
|
||||
writeFixture(
|
||||
dir,
|
||||
`packages/${name}/package.json`,
|
||||
JSON.stringify({ dependencies: deps }, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function appPkg(dir, name, deps) {
|
||||
writeFixture(
|
||||
dir,
|
||||
`apps/${name}/package.json`,
|
||||
JSON.stringify({ dependencies: deps }, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function traceFixture(dir, depName, lastRevalidated) {
|
||||
const lr = lastRevalidated == null ? "null" : lastRevalidated;
|
||||
const fm = `package: ${depName}
|
||||
version: "^1.0.0"
|
||||
tier: feature
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
lastRevalidated: ${lr}
|
||||
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
|
||||
socketRisk: clean
|
||||
verification-commands:
|
||||
- pnpm audit`;
|
||||
writeFixture(
|
||||
dir,
|
||||
`docs/library-decisions/2026-05-14-${depName}.md`,
|
||||
`---\n${fm}\n---\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildLockfileDiff(pkg, fromVer, toVer) {
|
||||
return [
|
||||
"--- a/pnpm-lock.yaml",
|
||||
"+++ b/pnpm-lock.yaml",
|
||||
"@@ -10,7 +10,7 @@ packages:",
|
||||
`- ${pkg}@${fromVer}: {}`,
|
||||
`+ ${pkg}@${toVer}: {}`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
describe("checkRenovatePr", () => {
|
||||
test("minor bump on feature-tier dep → pass", () => {
|
||||
const dir = makeTempDir();
|
||||
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
|
||||
|
||||
const errs = checkRenovatePr(dir, {
|
||||
branch: "renovate/some-lib-1.1.0",
|
||||
diff: buildLockfileDiff("some-lib", "1.0.0", "1.1.0"),
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("major bump + fresh lastRevalidated → pass", () => {
|
||||
const dir = makeTempDir();
|
||||
featurePkg(dir, "feat-a", { "some-lib": "^2.0.0" });
|
||||
traceFixture(dir, "some-lib", "2026-05-14");
|
||||
|
||||
const errs = checkRenovatePr(dir, {
|
||||
branch: "renovate/some-lib-2.0.0",
|
||||
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("major bump + stale lastRevalidated → fail with pointer", () => {
|
||||
const dir = makeTempDir();
|
||||
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
|
||||
traceFixture(dir, "some-lib", "2026-01-01");
|
||||
|
||||
const errs = checkRenovatePr(dir, {
|
||||
branch: "renovate/some-lib-2.0.0",
|
||||
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(errs.length, 1);
|
||||
assert.equal(errs[0].dep, "some-lib");
|
||||
assert.equal(errs[0].from, "1.2.3");
|
||||
assert.equal(errs[0].to, "2.0.0");
|
||||
assert.equal(errs[0].reason, "stale");
|
||||
assert.equal(errs[0].lastRevalidated, "2026-01-01");
|
||||
assert.ok(typeof errs[0].tracePath === "string");
|
||||
assert.ok(errs[0].tracePath.includes("some-lib"));
|
||||
});
|
||||
|
||||
test("major bump on app-tier dep → pass", () => {
|
||||
const dir = makeTempDir();
|
||||
// dep only in apps/, not in packages/ → app-tier exemption
|
||||
appPkg(dir, "web", { "some-lib": "^1.0.0" });
|
||||
|
||||
const errs = checkRenovatePr(dir, {
|
||||
branch: "renovate/some-lib-2.0.0",
|
||||
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("patch bump in Renovate branch → pass", () => {
|
||||
const dir = makeTempDir();
|
||||
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
|
||||
|
||||
const errs = checkRenovatePr(dir, {
|
||||
branch: "renovate/some-lib-1.2.4",
|
||||
diff: buildLockfileDiff("some-lib", "1.2.3", "1.2.4"),
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
|
||||
test("non-Renovate branch with major bump → pass", () => {
|
||||
const dir = makeTempDir();
|
||||
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
|
||||
|
||||
const errs = checkRenovatePr(dir, {
|
||||
branch: "main",
|
||||
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.deepEqual(errs, []);
|
||||
});
|
||||
});
|
||||
336
scripts/library-decisions/revalidate.mjs
Normal file
336
scripts/library-decisions/revalidate.mjs
Normal file
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Weekly trace revalidation: re-runs verification-commands for every
|
||||
* approved/pre-shipped trace, classifies soft/hard divergence, and manages
|
||||
* GitHub issues via the gh CLI.
|
||||
*
|
||||
* Soft drift → rolling "library-policy/dashboard" issue (create or update)
|
||||
* Hard drift → per-dep "library-policy/re-evaluation" issue (skip duplicates)
|
||||
* Refreshed → close open re-evaluation issue when lastRevalidated set + clean
|
||||
* Rejected → skipped entirely
|
||||
*/
|
||||
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseFrontmatter } from "./schema.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
const LABEL_DASHBOARD = "library-policy/dashboard";
|
||||
const LABEL_RE_EVAL = "library-policy/re-evaluation";
|
||||
|
||||
// Patterns in command output that signal hard divergence (evaluation would change)
|
||||
const HARD_PATTERNS = [
|
||||
/CVE-\d{4}-\d{4,}/i,
|
||||
/\babandoned\b/i,
|
||||
/\bhigh\s+severity\b/i,
|
||||
/\bcritical\s+severity\b/i,
|
||||
];
|
||||
|
||||
// Patterns in command output that signal soft divergence (minor drift)
|
||||
const SOFT_PATTERNS = [
|
||||
/\bdormant\b/i,
|
||||
/\bwarning\b/i,
|
||||
/\boutdated\b/i,
|
||||
/\bdeprecated\b/i,
|
||||
];
|
||||
|
||||
// ---- Trace discovery ----
|
||||
|
||||
function findAllTraceFiles(traceDir) {
|
||||
const files = [];
|
||||
if (!fs.existsSync(traceDir)) return files;
|
||||
|
||||
for (const entry of fs.readdirSync(traceDir)) {
|
||||
if (entry.startsWith("_")) continue;
|
||||
const fullPath = path.join(traceDir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isFile() && entry.endsWith(".md")) {
|
||||
files.push(fullPath);
|
||||
} else if (stat.isDirectory()) {
|
||||
// Scoped packages: date-@scope/ directory containing name.md files
|
||||
for (const sub of fs.readdirSync(fullPath)) {
|
||||
if (sub.endsWith(".md") && !sub.startsWith("_")) {
|
||||
files.push(path.join(fullPath, sub));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ---- Command execution ----
|
||||
|
||||
function defaultCommandRunner(cmd, cwd) {
|
||||
try {
|
||||
const stdout = execSync(cmd, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return { exitCode: 0, output: stdout };
|
||||
} catch (e) {
|
||||
const out = (e.stdout ?? "") + (e.stderr ?? "");
|
||||
return { exitCode: e.status ?? 1, output: out };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Classification ----
|
||||
|
||||
function classifyOutput(exitCode, output) {
|
||||
if (exitCode !== 0) {
|
||||
const snippet =
|
||||
output.trim().slice(0, 300) || `command failed (exit ${exitCode})`;
|
||||
return { kind: "hard", finding: snippet };
|
||||
}
|
||||
for (const re of HARD_PATTERNS) {
|
||||
const m = output.match(re);
|
||||
if (m) return { kind: "hard", finding: m[0] };
|
||||
}
|
||||
for (const re of SOFT_PATTERNS) {
|
||||
const m = output.match(re);
|
||||
if (m) return { kind: "soft", finding: m[0] };
|
||||
}
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
function revalidateTrace(fm, commandRunner, repoRoot) {
|
||||
const raw = fm["verification-commands"];
|
||||
const cmds = Array.isArray(raw) ? raw : [];
|
||||
let softFinding = null;
|
||||
|
||||
for (const cmd of cmds) {
|
||||
const { exitCode, output } = commandRunner(cmd, repoRoot);
|
||||
const result = classifyOutput(exitCode, output);
|
||||
if (result.kind === "hard") {
|
||||
return { status: "hard", finding: result.finding };
|
||||
}
|
||||
if (result.kind === "soft" && softFinding === null) {
|
||||
softFinding = result.finding;
|
||||
}
|
||||
}
|
||||
|
||||
return softFinding !== null
|
||||
? { status: "soft", finding: softFinding }
|
||||
: { status: "ok", finding: null };
|
||||
}
|
||||
|
||||
// ---- GitHub issue helpers ----
|
||||
|
||||
function defaultGhRunner(args) {
|
||||
const result = spawnSync("gh", args, { encoding: "utf8" });
|
||||
return { exitCode: result.status ?? 0, output: result.stdout ?? "" };
|
||||
}
|
||||
|
||||
function listOpenIssues(label, ghRunner) {
|
||||
const { output } = ghRunner([
|
||||
"issue",
|
||||
"list",
|
||||
"--label",
|
||||
label,
|
||||
"--state",
|
||||
"open",
|
||||
"--json",
|
||||
"number,title,body",
|
||||
]);
|
||||
try {
|
||||
return JSON.parse(output || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createIssue(title, label, body, ghRunner) {
|
||||
ghRunner([
|
||||
"issue",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--label",
|
||||
label,
|
||||
"--body",
|
||||
body,
|
||||
]);
|
||||
}
|
||||
|
||||
function updateIssue(number, body, ghRunner) {
|
||||
ghRunner(["issue", "edit", String(number), "--body", body]);
|
||||
}
|
||||
|
||||
function closeIssue(number, comment, ghRunner) {
|
||||
ghRunner(["issue", "close", String(number), "--comment", comment]);
|
||||
}
|
||||
|
||||
// ---- Dashboard body ----
|
||||
|
||||
function buildDashboardBody(softResults, today) {
|
||||
return [
|
||||
`## Library trace soft drift — ${today}`,
|
||||
"",
|
||||
"The following traces have minor drift in their verification commands.",
|
||||
"These discrepancies do not immediately require re-evaluation but should be reviewed.",
|
||||
"",
|
||||
"| Package | Finding |",
|
||||
"| ------- | ------- |",
|
||||
...softResults.map((r) => `| \`${r.pkg}@${r.version}\` | ${r.finding} |`),
|
||||
"",
|
||||
"To refresh a trace, run the `/evaluate-library` skill and update `lastRevalidated`.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ---- Main export ----
|
||||
|
||||
/**
|
||||
* Walk all approved/pre-shipped traces, re-run their verification-commands,
|
||||
* classify divergence, and manage GitHub issues accordingly.
|
||||
*
|
||||
* Returns { hard: [...], soft: [...] } for inspection / testing.
|
||||
*/
|
||||
export function revalidate(repoRoot = DEFAULT_REPO_ROOT, options = {}) {
|
||||
const {
|
||||
commandRunner = defaultCommandRunner,
|
||||
ghRunner = defaultGhRunner,
|
||||
today = new Date().toISOString().slice(0, 10),
|
||||
} = options;
|
||||
|
||||
const traceDir = path.join(repoRoot, "docs", "library-decisions");
|
||||
const traceFiles = findAllTraceFiles(traceDir);
|
||||
|
||||
const hardResults = [];
|
||||
const softResults = [];
|
||||
const cleanResults = []; // status: ok
|
||||
|
||||
for (const tracePath of traceFiles) {
|
||||
let fm;
|
||||
try {
|
||||
const content = fs.readFileSync(tracePath, "utf8");
|
||||
fm = parseFrontmatter(content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fm.decision !== "approved" && fm.decision !== "pre-shipped") continue;
|
||||
|
||||
const { status, finding } = revalidateTrace(fm, commandRunner, repoRoot);
|
||||
|
||||
const entry = {
|
||||
tracePath,
|
||||
pkg: fm.package,
|
||||
version: fm.version,
|
||||
lastRevalidated: fm.lastRevalidated ?? null,
|
||||
finding,
|
||||
};
|
||||
|
||||
if (status === "hard") hardResults.push(entry);
|
||||
else if (status === "soft") softResults.push(entry);
|
||||
else cleanResults.push(entry);
|
||||
}
|
||||
|
||||
// Phase 1: close stale re-evaluation issues for deps that have since been
|
||||
// re-evaluated (lastRevalidated set) and currently show no hard drift.
|
||||
const openRevalIssues = listOpenIssues(LABEL_RE_EVAL, ghRunner);
|
||||
const closedNums = new Set();
|
||||
|
||||
for (const issue of openRevalIssues) {
|
||||
const m = issue.title.match(/^re-evaluate:\s+(.+?)@/);
|
||||
if (!m) continue;
|
||||
const issuePkg = m[1].trim();
|
||||
|
||||
const cleanEntry = cleanResults.find(
|
||||
(e) => e.pkg === issuePkg && e.lastRevalidated != null,
|
||||
);
|
||||
if (cleanEntry) {
|
||||
closeIssue(
|
||||
issue.number,
|
||||
`Closing: \`${issuePkg}\` trace was revalidated on ${cleanEntry.lastRevalidated}. ` +
|
||||
`No hard drift detected in latest run. Run \`/evaluate-library\` for a full re-walk if needed.`,
|
||||
ghRunner,
|
||||
);
|
||||
closedNums.add(issue.number);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: open per-dep issues for hard drift, skipping duplicates.
|
||||
for (const result of hardResults) {
|
||||
const alreadyOpen = openRevalIssues.some(
|
||||
(i) =>
|
||||
!closedNums.has(i.number) &&
|
||||
i.title.includes(`re-evaluate: ${result.pkg}@`),
|
||||
);
|
||||
if (alreadyOpen) continue;
|
||||
|
||||
const titleFinding = result.finding.split("\n")[0].slice(0, 80).trim();
|
||||
const title = `re-evaluate: ${result.pkg}@${result.version} — ${titleFinding}`;
|
||||
const body = [
|
||||
`## Revalidation finding`,
|
||||
"",
|
||||
`**Package:** \`${result.pkg}@${result.version}\``,
|
||||
`**Trace:** \`${path.relative(repoRoot, result.tracePath)}\``,
|
||||
`**Finding:** ${result.finding}`,
|
||||
"",
|
||||
"## Next steps",
|
||||
"",
|
||||
"Run the `/evaluate-library` skill to re-walk the evaluation for this package:",
|
||||
"```",
|
||||
".claude/skills/evaluate-library/SKILL.md",
|
||||
"```",
|
||||
"",
|
||||
`> Generated by the weekly trace revalidation workflow on ${today}.`,
|
||||
].join("\n");
|
||||
|
||||
createIssue(title, LABEL_RE_EVAL, body, ghRunner);
|
||||
}
|
||||
|
||||
// Phase 3: update rolling dashboard issue for soft drift.
|
||||
if (softResults.length > 0) {
|
||||
const dashboardBody = buildDashboardBody(softResults, today);
|
||||
const openDashboard = listOpenIssues(LABEL_DASHBOARD, ghRunner);
|
||||
if (openDashboard.length > 0) {
|
||||
updateIssue(openDashboard[0].number, dashboardBody, ghRunner);
|
||||
} else {
|
||||
createIssue(
|
||||
`Library trace drift dashboard — ${today}`,
|
||||
LABEL_DASHBOARD,
|
||||
dashboardBody,
|
||||
ghRunner,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { hard: hardResults, soft: softResults };
|
||||
}
|
||||
|
||||
// ---- CLI entry point ----
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const result = revalidate();
|
||||
|
||||
if (result.hard.length === 0 && result.soft.length === 0) {
|
||||
console.log("✓ All traces clean — no drift detected.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (result.hard.length > 0) {
|
||||
console.log(
|
||||
`\n✗ Hard drift detected for ${result.hard.length} package(s):`,
|
||||
);
|
||||
for (const r of result.hard) {
|
||||
console.log(` ${r.pkg}@${r.version}: ${r.finding}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.soft.length > 0) {
|
||||
console.log(
|
||||
`\n⚠ Soft drift detected for ${result.soft.length} package(s):`,
|
||||
);
|
||||
for (const r of result.soft) {
|
||||
console.log(` ${r.pkg}@${r.version}: ${r.finding}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
513
scripts/library-decisions/revalidate.test.mjs
Normal file
513
scripts/library-decisions/revalidate.test.mjs
Normal file
@@ -0,0 +1,513 @@
|
||||
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 { revalidate } from "./revalidate.mjs";
|
||||
|
||||
// ---- Fixture helpers ----
|
||||
|
||||
function makeTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "revalidate-"));
|
||||
}
|
||||
|
||||
function writeTrace(dir, pkg, opts = {}) {
|
||||
const {
|
||||
decision = "approved",
|
||||
lastRevalidated = null,
|
||||
commands = ["echo ok"],
|
||||
socketRisk = "clean",
|
||||
} = opts;
|
||||
|
||||
const lr = lastRevalidated == null ? "null" : lastRevalidated;
|
||||
const cmdLines = commands.map((c) => ` - ${c}`).join("\n");
|
||||
|
||||
const content = `---
|
||||
package: ${pkg}
|
||||
version: "^1.0.0"
|
||||
tier: feature
|
||||
decision: ${decision}
|
||||
date: 2026-05-14
|
||||
deciders: [alice]
|
||||
adr: null
|
||||
lastRevalidated: ${lr}
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: ok
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
socketRisk: ${socketRisk}
|
||||
verification-commands:
|
||||
${cmdLines}
|
||||
---
|
||||
|
||||
## Body
|
||||
`;
|
||||
|
||||
const traceDir = path.join(dir, "docs", "library-decisions");
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(traceDir, `2026-05-14-${pkg}.md`), content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock command runner that maps exact command strings to results.
|
||||
* Unrecognised commands return { exitCode: 0, output: "" } by default.
|
||||
*/
|
||||
function makeCommandMock(responses = {}) {
|
||||
const calls = [];
|
||||
function commandRunner(cmd) {
|
||||
calls.push(cmd);
|
||||
const r = responses[cmd];
|
||||
return r ?? { exitCode: 0, output: "" };
|
||||
}
|
||||
return { commandRunner, calls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock gh CLI runner. Accepts an initial set of open issues keyed by
|
||||
* label. Tracks all calls; `gh issue create` appends to the label bucket.
|
||||
*/
|
||||
function makeGhMock(initialIssuesByLabel = {}) {
|
||||
const calls = [];
|
||||
const issuesByLabel = JSON.parse(JSON.stringify(initialIssuesByLabel));
|
||||
let nextNumber = 1000;
|
||||
|
||||
function ghRunner(args) {
|
||||
calls.push([...args]);
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
const labelIdx = args.indexOf("--label");
|
||||
const label = labelIdx >= 0 ? args[labelIdx + 1] : "";
|
||||
return {
|
||||
exitCode: 0,
|
||||
output: JSON.stringify(issuesByLabel[label] ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "create") {
|
||||
const titleIdx = args.indexOf("--title");
|
||||
const labelIdx = args.indexOf("--label");
|
||||
const title = titleIdx >= 0 ? args[titleIdx + 1] : "Untitled";
|
||||
const label = labelIdx >= 0 ? args[labelIdx + 1] : "";
|
||||
if (label) {
|
||||
issuesByLabel[label] = issuesByLabel[label] ?? [];
|
||||
issuesByLabel[label].push({ number: ++nextNumber, title, body: "" });
|
||||
}
|
||||
return { exitCode: 0, output: "" };
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "edit") {
|
||||
return { exitCode: 0, output: "" };
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "close") {
|
||||
const num = parseInt(args[2], 10);
|
||||
for (const label of Object.keys(issuesByLabel)) {
|
||||
issuesByLabel[label] = issuesByLabel[label].filter(
|
||||
(i) => i.number !== num,
|
||||
);
|
||||
}
|
||||
return { exitCode: 0, output: "" };
|
||||
}
|
||||
|
||||
return { exitCode: 0, output: "" };
|
||||
}
|
||||
|
||||
return { ghRunner, calls, issuesByLabel };
|
||||
}
|
||||
|
||||
function createCalls(calls) {
|
||||
return calls.filter((a) => a[0] === "issue" && a[1] === "create");
|
||||
}
|
||||
|
||||
function closeCalls(calls) {
|
||||
return calls.filter((a) => a[0] === "issue" && a[1] === "close");
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe("revalidate", () => {
|
||||
test("no-drift trace → no issue created or closed", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "clean-lib", {
|
||||
commands: ["echo all-good"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"echo all-good": { exitCode: 0, output: "all-good" },
|
||||
});
|
||||
const { ghRunner, calls } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.deepEqual(result.hard, []);
|
||||
assert.deepEqual(result.soft, []);
|
||||
assert.equal(createCalls(calls).length, 0);
|
||||
assert.equal(closeCalls(calls).length, 0);
|
||||
});
|
||||
|
||||
test("soft-drift trace → dashboard issue created", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "drifting-lib", {
|
||||
commands: ["npm view drifting-lib version"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"npm view drifting-lib version": {
|
||||
exitCode: 0,
|
||||
output: "package is dormant — no recent releases",
|
||||
},
|
||||
});
|
||||
const { ghRunner, calls } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(result.soft.length, 1);
|
||||
assert.equal(result.soft[0].pkg, "drifting-lib");
|
||||
|
||||
const created = createCalls(calls);
|
||||
assert.equal(created.length, 1);
|
||||
|
||||
const titleIdx = created[0].indexOf("--title");
|
||||
const labelIdx = created[0].indexOf("--label");
|
||||
const title = created[0][titleIdx + 1];
|
||||
const label = created[0][labelIdx + 1];
|
||||
|
||||
assert.ok(
|
||||
title.startsWith("Library trace drift dashboard"),
|
||||
`title: ${title}`,
|
||||
);
|
||||
assert.equal(label, "library-policy/dashboard");
|
||||
});
|
||||
|
||||
test("soft-drift with existing dashboard issue → issue updated, not duplicated", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "drifting-lib", {
|
||||
commands: ["echo outdated package"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"echo outdated package": {
|
||||
exitCode: 0,
|
||||
output: "outdated package detected",
|
||||
},
|
||||
});
|
||||
const existingIssue = {
|
||||
number: 55,
|
||||
title: "Library trace drift dashboard — 2026-05-07",
|
||||
body: "",
|
||||
};
|
||||
const { ghRunner, calls } = makeGhMock({
|
||||
"library-policy/dashboard": [existingIssue],
|
||||
});
|
||||
|
||||
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
|
||||
|
||||
assert.equal(
|
||||
createCalls(calls).length,
|
||||
0,
|
||||
"should not create a new dashboard issue",
|
||||
);
|
||||
const editCalls = calls.filter((a) => a[0] === "issue" && a[1] === "edit");
|
||||
assert.equal(editCalls.length, 1);
|
||||
assert.equal(editCalls[0][2], "55");
|
||||
});
|
||||
|
||||
test("hard-drift trace → per-dep re-evaluation issue created with correct labels and title", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "risky-lib", {
|
||||
commands: ["pnpm audit --audit-level=moderate"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"pnpm audit --audit-level=moderate": {
|
||||
exitCode: 1,
|
||||
output: "high severity vulnerability in risky-lib",
|
||||
},
|
||||
});
|
||||
const { ghRunner, calls } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(result.hard.length, 1);
|
||||
assert.equal(result.hard[0].pkg, "risky-lib");
|
||||
|
||||
const created = createCalls(calls);
|
||||
assert.equal(created.length, 1);
|
||||
|
||||
const titleIdx = created[0].indexOf("--title");
|
||||
const labelIdx = created[0].indexOf("--label");
|
||||
const title = created[0][titleIdx + 1];
|
||||
const label = created[0][labelIdx + 1];
|
||||
|
||||
assert.ok(
|
||||
title.startsWith("re-evaluate: risky-lib@"),
|
||||
`title should start with "re-evaluate: risky-lib@", got: ${title}`,
|
||||
);
|
||||
assert.ok(
|
||||
title.includes(" — "),
|
||||
`title should contain em-dash separator, got: ${title}`,
|
||||
);
|
||||
assert.equal(label, "library-policy/re-evaluation");
|
||||
});
|
||||
|
||||
test("hard-drift with CVE in output → issue title includes CVE reference", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "cve-lib", {
|
||||
commands: ["socket scan cve-lib"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"socket scan cve-lib": {
|
||||
exitCode: 0,
|
||||
output: "CVE-2024-12345 found in transitive dependency",
|
||||
},
|
||||
});
|
||||
const { ghRunner, calls } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(result.hard.length, 1);
|
||||
|
||||
const created = createCalls(calls);
|
||||
assert.equal(created.length, 1);
|
||||
const titleIdx = created[0].indexOf("--title");
|
||||
assert.ok(
|
||||
created[0][titleIdx + 1].includes("CVE-2024-12345"),
|
||||
`title should reference the CVE`,
|
||||
);
|
||||
});
|
||||
|
||||
test("duplicate-issue guard → no second issue opened when open issue already exists", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "already-flagged", {
|
||||
commands: ["pnpm audit"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"pnpm audit": {
|
||||
exitCode: 1,
|
||||
output: "critical severity vulnerability",
|
||||
},
|
||||
});
|
||||
const existingIssue = {
|
||||
number: 77,
|
||||
title: "re-evaluate: already-flagged@^1.0.0 — previous finding",
|
||||
body: "",
|
||||
};
|
||||
const { ghRunner, calls } = makeGhMock({
|
||||
"library-policy/re-evaluation": [existingIssue],
|
||||
});
|
||||
|
||||
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
|
||||
|
||||
assert.equal(
|
||||
createCalls(calls).length,
|
||||
0,
|
||||
"should not open a duplicate re-evaluation issue",
|
||||
);
|
||||
});
|
||||
|
||||
test("stale-issue close on refreshed lastRevalidated → open issue closed with comment", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "refreshed-lib", {
|
||||
lastRevalidated: "2026-05-14",
|
||||
commands: ["npm view refreshed-lib license"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"npm view refreshed-lib license": { exitCode: 0, output: "MIT" },
|
||||
});
|
||||
const openIssue = {
|
||||
number: 42,
|
||||
title: "re-evaluate: refreshed-lib@^1.0.0 — old finding from last week",
|
||||
body: "",
|
||||
};
|
||||
const { ghRunner, calls } = makeGhMock({
|
||||
"library-policy/re-evaluation": [openIssue],
|
||||
});
|
||||
|
||||
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
|
||||
|
||||
const closed = closeCalls(calls);
|
||||
assert.equal(closed.length, 1, "should close the stale issue");
|
||||
assert.equal(closed[0][2], "42", "should close issue number 42");
|
||||
|
||||
const commentIdx = closed[0].indexOf("--comment");
|
||||
assert.ok(commentIdx >= 0, "close call should include --comment flag");
|
||||
assert.ok(
|
||||
closed[0][commentIdx + 1].includes("2026-05-14"),
|
||||
"comment should reference the revalidation date",
|
||||
);
|
||||
});
|
||||
|
||||
test("clean trace with null lastRevalidated does not close open issue", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "unrevalidated-lib", {
|
||||
lastRevalidated: null,
|
||||
commands: ["echo ok"],
|
||||
});
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"echo ok": { exitCode: 0, output: "ok" },
|
||||
});
|
||||
const openIssue = {
|
||||
number: 33,
|
||||
title: "re-evaluate: unrevalidated-lib@^1.0.0 — earlier finding",
|
||||
body: "",
|
||||
};
|
||||
const { ghRunner, calls } = makeGhMock({
|
||||
"library-policy/re-evaluation": [openIssue],
|
||||
});
|
||||
|
||||
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
|
||||
|
||||
assert.equal(
|
||||
closeCalls(calls).length,
|
||||
0,
|
||||
"should not close issue when lastRevalidated is null",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejected-trace skip → no commands run, no issues created", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "rejected-lib", {
|
||||
decision: "rejected",
|
||||
commands: ["pnpm audit"],
|
||||
});
|
||||
|
||||
let commandsCalled = 0;
|
||||
const commandRunner = () => {
|
||||
commandsCalled++;
|
||||
return { exitCode: 0, output: "" };
|
||||
};
|
||||
const { ghRunner, calls } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
commandsCalled,
|
||||
0,
|
||||
"should not run commands for rejected traces",
|
||||
);
|
||||
assert.equal(createCalls(calls).length, 0, "should not create any issues");
|
||||
assert.deepEqual(result.hard, []);
|
||||
assert.deepEqual(result.soft, []);
|
||||
});
|
||||
|
||||
test("pre-shipped trace is processed like approved", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "preshipped-lib", {
|
||||
decision: "pre-shipped",
|
||||
commands: ["echo clean"],
|
||||
});
|
||||
|
||||
const { commandRunner, calls: cmdCalls } = makeCommandMock({
|
||||
"echo clean": { exitCode: 0, output: "clean" },
|
||||
});
|
||||
const { ghRunner } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
cmdCalls.length,
|
||||
1,
|
||||
"should run commands for pre-shipped traces",
|
||||
);
|
||||
assert.deepEqual(result.hard, []);
|
||||
assert.deepEqual(result.soft, []);
|
||||
});
|
||||
|
||||
test("multiple traces: independent classification per trace", () => {
|
||||
const dir = makeTmpDir();
|
||||
writeTrace(dir, "clean-pkg", { commands: ["echo ok"] });
|
||||
writeTrace(dir, "soft-pkg", { commands: ["echo package is deprecated"] });
|
||||
writeTrace(dir, "hard-pkg", { commands: ["pnpm audit"] });
|
||||
|
||||
const { commandRunner } = makeCommandMock({
|
||||
"echo ok": { exitCode: 0, output: "ok" },
|
||||
"echo package is deprecated": {
|
||||
exitCode: 0,
|
||||
output: "package is deprecated",
|
||||
},
|
||||
"pnpm audit": { exitCode: 1, output: "vulnerability found" },
|
||||
});
|
||||
const { ghRunner, calls } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(result.hard.length, 1);
|
||||
assert.equal(result.hard[0].pkg, "hard-pkg");
|
||||
assert.equal(result.soft.length, 1);
|
||||
assert.equal(result.soft[0].pkg, "soft-pkg");
|
||||
|
||||
// one re-eval issue for hard, one dashboard issue for soft
|
||||
const created = createCalls(calls);
|
||||
assert.equal(created.length, 2);
|
||||
|
||||
const labels = created.map((c) => {
|
||||
const idx = c.indexOf("--label");
|
||||
return c[idx + 1];
|
||||
});
|
||||
assert.ok(labels.includes("library-policy/re-evaluation"));
|
||||
assert.ok(labels.includes("library-policy/dashboard"));
|
||||
});
|
||||
|
||||
test("trace with empty verification-commands → treated as ok (no drift)", () => {
|
||||
const dir = makeTmpDir();
|
||||
// writeTrace with commands:[] produces an empty block sequence which
|
||||
// parseFrontmatter returns as {} (object, not array). revalidate.mjs
|
||||
// must handle this gracefully.
|
||||
writeTrace(dir, "no-cmds-lib", { commands: [] });
|
||||
|
||||
const { commandRunner, calls: cmdCalls } = makeCommandMock();
|
||||
const { ghRunner, calls: ghCalls } = makeGhMock();
|
||||
|
||||
const result = revalidate(dir, {
|
||||
commandRunner,
|
||||
ghRunner,
|
||||
today: "2026-05-14",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
cmdCalls.length,
|
||||
0,
|
||||
"no commands should run for empty commands list",
|
||||
);
|
||||
assert.deepEqual(result.hard, []);
|
||||
assert.deepEqual(result.soft, []);
|
||||
assert.equal(createCalls(ghCalls).length, 0);
|
||||
});
|
||||
});
|
||||
145
scripts/library-decisions/schema.mjs
Normal file
145
scripts/library-decisions/schema.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
// 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);
|
||||
}
|
||||
220
scripts/library-decisions/schema.test.mjs
Normal file
220
scripts/library-decisions/schema.test.mjs
Normal file
@@ -0,0 +1,220 @@
|
||||
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,
|
||||
lastRevalidated: null,
|
||||
"filter-results": {
|
||||
license: "MIT",
|
||||
types: "native",
|
||||
maintenance: "active",
|
||||
"boundary-fit": "pass",
|
||||
"shadow-check": "pass",
|
||||
"eu-residency": "ok",
|
||||
"cve-scan": "clean",
|
||||
"named-consumer": "pass",
|
||||
socketRisk: "clean",
|
||||
},
|
||||
"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
|
||||
lastRevalidated: null
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: ok
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
socketRisk: clean
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
test("missing socketRisk in filter-results fails validation", () => {
|
||||
const raw = validRaw();
|
||||
delete raw["filter-results"].socketRisk;
|
||||
assert.throws(() => validateTrace(raw), /invalid_type|Required/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTrace > socketRisk", () => {
|
||||
test('socketRisk "clean" round-trips', () => {
|
||||
const result = validateTrace(validRaw());
|
||||
assert.equal(result["filter-results"].socketRisk, "clean");
|
||||
});
|
||||
|
||||
test('socketRisk "flagged" round-trips', () => {
|
||||
const raw = validRaw({
|
||||
"filter-results": {
|
||||
...validRaw()["filter-results"],
|
||||
socketRisk: "flagged",
|
||||
},
|
||||
});
|
||||
assert.equal(validateTrace(raw)["filter-results"].socketRisk, "flagged");
|
||||
});
|
||||
|
||||
test("socketRisk arbitrary string round-trips", () => {
|
||||
const raw = validRaw({
|
||||
"filter-results": {
|
||||
...validRaw()["filter-results"],
|
||||
socketRisk: "obfuscated-code",
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
validateTrace(raw)["filter-results"].socketRisk,
|
||||
"obfuscated-code",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTrace > lastRevalidated", () => {
|
||||
test("lastRevalidated null is valid", () => {
|
||||
assert.equal(
|
||||
validateTrace(validRaw({ lastRevalidated: null })).lastRevalidated,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("lastRevalidated ISO date string is valid", () => {
|
||||
assert.equal(
|
||||
validateTrace(validRaw({ lastRevalidated: "2026-05-14" }))
|
||||
.lastRevalidated,
|
||||
"2026-05-14",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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