diff --git a/.husky/pre-commit b/.husky/pre-commit index dfbf41f..92c418a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -16,3 +16,6 @@ fi # staged but doesn't match what rebuild-state would produce. Catches the case # where someone hand-edits _state.json without going through rebuild-state. node scripts/work/state-sync-guard.mjs || exit 1 + +# 4. Check library decision traces for new runtime deps in feature/core packages. +node scripts/library-decisions/check.mjs || exit 1 diff --git a/scripts/library-decisions/check.mjs b/scripts/library-decisions/check.mjs new file mode 100644 index 0000000..1bdfae8 --- /dev/null +++ b/scripts/library-decisions/check.mjs @@ -0,0 +1,155 @@ +#!/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 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) { + return execSync("git diff --cached --name-only", { + 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. + */ +function getNewRuntimeDeps(relPath, repoRoot) { + let staged; + try { + staged = JSON.parse( + execSync(`git show ":${relPath}"`, { cwd: repoRoot, encoding: "utf8" }), + ); + } catch { + return []; + } + let base = {}; + try { + base = JSON.parse( + execSync(`git show "HEAD:${relPath}"`, { + cwd: repoRoot, + encoding: "utf8", + }), + ); + } catch { + // New file or initial commit — treat all staged deps as new + } + const baseDeps = new Set(Object.keys(base.dependencies ?? {})); + return Object.keys(staged.dependencies ?? {}).filter((d) => !baseDeps.has(d)); +} + +/** + * Search the staged-files list for a trace file whose name ends with + * `-.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; +} + +/** + * 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) { + const staged = stagedFilesList(repoRoot); + 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)) { + const traceFile = findStagedTrace(dep, staged); + if (!traceFile) { + errors.push({ pkgJson: relPath, dep, reason: "no-trace" }); + continue; + } + try { + const content = execSync(`git show ":${traceFile}"`, { + 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 errors = checkLibraryDecisions(); + 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); +} diff --git a/scripts/library-decisions/check.test.mjs b/scripts/library-decisions/check.test.mjs new file mode 100644 index 0000000..31b0708 --- /dev/null +++ b/scripts/library-decisions/check.test.mjs @@ -0,0 +1,166 @@ +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 } 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), []); + }); +});