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>
156 lines
4.6 KiB
JavaScript
156 lines
4.6 KiB
JavaScript
#!/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);
|
|
}
|