feat(scripts): add pre-commit library-decisions check + tests
Adds scripts/library-decisions/check.mjs that walks staged package.json diffs, derives tier from path, and fails the commit when a new runtime dependency in a feature- or core-tier package has no sibling approved trace staged in docs/library-decisions/. App-tier additions and devDependency / peerDependency additions are silently allowed. Wired into .husky/pre-commit as step 4. check.test.mjs covers all 7 Done-when cases using temp git repo fixtures (node:test + node:assert, same pattern as schema.test.mjs). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
155
scripts/library-decisions/check.mjs
Normal file
155
scripts/library-decisions/check.mjs
Normal file
@@ -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
|
||||
* `-<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
166
scripts/library-decisions/check.test.mjs
Normal file
166
scripts/library-decisions/check.test.mjs
Normal file
@@ -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), []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user